From da77ac65ebf7f383f2358e32194a89ad442e5f8b Mon Sep 17 00:00:00 2001 From: meow Date: Fri, 25 Oct 2019 11:42:40 +0500 Subject: [PATCH 001/284] ucloud-{api,scheduler,host,filescanner,imagescanner,metadata} combined --- .gitignore | 5 + Pipfile | 22 + Pipfile.lock | 773 +++++++++++++++++++ TODO.md | 6 + api/README.md | 12 + api/common_fields.py | 48 ++ api/config.py | 32 + api/create_image_store.py | 17 + api/helper.py | 70 ++ api/main.py | 380 +++++++++ api/schemas.py | 415 ++++++++++ filescanner/main.py | 109 +++ host/config.py | 32 + host/main.py | 137 ++++ host/qmp/__init__.py | 534 +++++++++++++ host/qmp/__pycache__/__init__.cpython-37.pyc | Bin 0 -> 15086 bytes host/qmp/__pycache__/qmp.cpython-37.pyc | Bin 0 -> 8571 bytes host/qmp/qmp.py | 256 ++++++ host/virtualmachine.py | 310 ++++++++ imagescanner/config.py | 22 + imagescanner/main.py | 108 +++ metadata/config.py | 21 + metadata/main.py | 84 ++ scheduler/config.py | 25 + scheduler/helper.py | 123 +++ scheduler/main.py | 89 +++ scheduler/tests/test_basics.py | 214 +++++ scheduler/tests/test_dead_host_mechanism.py | 81 ++ ucloud.py | 16 + 29 files changed, 3941 insertions(+) create mode 100644 .gitignore create mode 100644 Pipfile create mode 100644 Pipfile.lock create mode 100644 TODO.md create mode 100755 api/README.md create mode 100755 api/common_fields.py create mode 100644 api/config.py create mode 100755 api/create_image_store.py create mode 100755 api/helper.py create mode 100644 api/main.py create mode 100755 api/schemas.py create mode 100755 filescanner/main.py create mode 100755 host/config.py create mode 100755 host/main.py create mode 100755 host/qmp/__init__.py create mode 100755 host/qmp/__pycache__/__init__.cpython-37.pyc create mode 100755 host/qmp/__pycache__/qmp.cpython-37.pyc create mode 100755 host/qmp/qmp.py create mode 100755 host/virtualmachine.py create mode 100755 imagescanner/config.py create mode 100755 imagescanner/main.py create mode 100644 metadata/config.py create mode 100644 metadata/main.py create mode 100755 scheduler/config.py create mode 100755 scheduler/helper.py create mode 100755 scheduler/main.py create mode 100755 scheduler/tests/test_basics.py create mode 100755 scheduler/tests/test_dead_host_mechanism.py create mode 100644 ucloud.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5430a7a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea +.vscode +.env + +*/log.txt \ No newline at end of file diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..5db945c --- /dev/null +++ b/Pipfile @@ -0,0 +1,22 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] +prospector = {extras = ["with_everything"],version = "*"} + +[packages] +python-decouple = "*" +requests = "*" +flask = "*" +flask-restful = "*" +bitmath = "*" +ucloud-common = {editable = true,git = "git+https://code.ungleich.ch/ucloud/ucloud_common.git",ref = "wip"} +etcd3-wrapper = {editable = true,git = "git+https://code.ungleich.ch/ungleich-public/etcd3_wrapper.git",ref = "wip"} +python-etcd3 = {editable = true,git = "git+https://github.com/kragniz/python-etcd3.git"} +pyotp = "*" +sshtunnel = "*" + +[requires] +python_version = "3.5" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..aaa7369 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,773 @@ +{ + "_meta": { + "hash": { + "sha256": "b7a8409bec451e017440f063d8436fe66b18affcde7ad5497b433191ae465a52" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.5" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "aniso8601": { + "hashes": [ + "sha256:529dcb1f5f26ee0df6c0a1ee84b7b27197c3c50fc3a6321d66c544689237d072", + "sha256:c033f63d028b9a58e3ab0c2c7d0532ab4bfa7452bfc788fbfe3ddabd327b181a" + ], + "version": "==8.0.0" + }, + "bcrypt": { + "hashes": [ + "sha256:0258f143f3de96b7c14f762c770f5fc56ccd72f8a1857a451c1cd9a655d9ac89", + "sha256:0b0069c752ec14172c5f78208f1863d7ad6755a6fae6fe76ec2c80d13be41e42", + "sha256:19a4b72a6ae5bb467fea018b825f0a7d917789bcfe893e53f15c92805d187294", + "sha256:5432dd7b34107ae8ed6c10a71b4397f1c853bd39a4d6ffa7e35f40584cffd161", + "sha256:6305557019906466fc42dbc53b46da004e72fd7a551c044a827e572c82191752", + "sha256:69361315039878c0680be456640f8705d76cb4a3a3fe1e057e0f261b74be4b31", + "sha256:6fe49a60b25b584e2f4ef175b29d3a83ba63b3a4df1b4c0605b826668d1b6be5", + "sha256:74a015102e877d0ccd02cdeaa18b32aa7273746914a6c5d0456dd442cb65b99c", + "sha256:763669a367869786bb4c8fcf731f4175775a5b43f070f50f46f0b59da45375d0", + "sha256:8b10acde4e1919d6015e1df86d4c217d3b5b01bb7744c36113ea43d529e1c3de", + "sha256:9fe92406c857409b70a38729dbdf6578caf9228de0aef5bc44f859ffe971a39e", + "sha256:a190f2a5dbbdbff4b74e3103cef44344bc30e61255beb27310e2aec407766052", + "sha256:a595c12c618119255c90deb4b046e1ca3bcfad64667c43d1166f2b04bc72db09", + "sha256:c9457fa5c121e94a58d6505cadca8bed1c64444b83b3204928a866ca2e599105", + "sha256:cb93f6b2ab0f6853550b74e051d297c27a638719753eb9ff66d1e4072be67133", + "sha256:ce4e4f0deb51d38b1611a27f330426154f2980e66582dc5f438aad38b5f24fc1", + "sha256:d7bdc26475679dd073ba0ed2766445bb5b20ca4793ca0db32b399dccc6bc84b7", + "sha256:ff032765bb8716d9387fd5376d987a937254b0619eff0972779515b5c98820bc" + ], + "version": "==3.1.7" + }, + "bitmath": { + "hashes": [ + "sha256:293325f01e65defe966853111df11d39215eb705a967cb115851da8c4cfa3eb8" + ], + "index": "pypi", + "version": "==1.3.3.1" + }, + "certifi": { + "hashes": [ + "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", + "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef" + ], + "version": "==2019.9.11" + }, + "cffi": { + "hashes": [ + "sha256:00d890313797d9fe4420506613384b43099ad7d2b905c0752dbcc3a6f14d80fa", + "sha256:0cf9e550ac6c5e57b713437e2f4ac2d7fd0cd10336525a27224f5fc1ec2ee59a", + "sha256:0ea23c9c0cdd6778146a50d867d6405693ac3b80a68829966c98dd5e1bbae400", + "sha256:193697c2918ecdb3865acf6557cddf5076bb39f1f654975e087b67efdff83365", + "sha256:1ae14b542bf3b35e5229439c35653d2ef7d8316c1fffb980f9b7647e544baa98", + "sha256:1e389e069450609c6ffa37f21f40cce36f9be7643bbe5051ab1de99d5a779526", + "sha256:263242b6ace7f9cd4ea401428d2d45066b49a700852334fd55311bde36dcda14", + "sha256:33142ae9807665fa6511cfa9857132b2c3ee6ddffb012b3f0933fc11e1e830d5", + "sha256:364f8404034ae1b232335d8c7f7b57deac566f148f7222cef78cf8ae28ef764e", + "sha256:47368f69fe6529f8f49a5d146ddee713fc9057e31d61e8b6dc86a6a5e38cecc1", + "sha256:4895640844f17bec32943995dc8c96989226974dfeb9dd121cc45d36e0d0c434", + "sha256:558b3afef987cf4b17abd849e7bedf64ee12b28175d564d05b628a0f9355599b", + "sha256:5ba86e1d80d458b338bda676fd9f9d68cb4e7a03819632969cf6d46b01a26730", + "sha256:63424daa6955e6b4c70dc2755897f5be1d719eabe71b2625948b222775ed5c43", + "sha256:6381a7d8b1ebd0bc27c3bc85bc1bfadbb6e6f756b4d4db0aa1425c3719ba26b4", + "sha256:6381ab708158c4e1639da1f2a7679a9bbe3e5a776fc6d1fd808076f0e3145331", + "sha256:6fd58366747debfa5e6163ada468a90788411f10c92597d3b0a912d07e580c36", + "sha256:728ec653964655d65408949b07f9b2219df78badd601d6c49e28d604efe40599", + "sha256:7cfcfda59ef1f95b9f729c56fe8a4041899f96b72685d36ef16a3440a0f85da8", + "sha256:819f8d5197c2684524637f940445c06e003c4a541f9983fd30d6deaa2a5487d8", + "sha256:825ecffd9574557590e3225560a8a9d751f6ffe4a49e3c40918c9969b93395fa", + "sha256:9009e917d8f5ef780c2626e29b6bc126f4cb2a4d43ca67aa2b40f2a5d6385e78", + "sha256:9c77564a51d4d914ed5af096cd9843d90c45b784b511723bd46a8a9d09cf16fc", + "sha256:a19089fa74ed19c4fe96502a291cfdb89223a9705b1d73b3005df4256976142e", + "sha256:a40ed527bffa2b7ebe07acc5a3f782da072e262ca994b4f2085100b5a444bbb2", + "sha256:bb75ba21d5716abc41af16eac1145ab2e471deedde1f22c6f99bd9f995504df0", + "sha256:e22a00c0c81ffcecaf07c2bfb3672fa372c50e2bd1024ffee0da191c1b27fc71", + "sha256:e55b5a746fb77f10c83e8af081979351722f6ea48facea79d470b3731c7b2891", + "sha256:ec2fa3ee81707a5232bf2dfbd6623fdb278e070d596effc7e2d788f2ada71a05", + "sha256:fd82eb4694be712fcae03c717ca2e0fc720657ac226b80bbb597e971fc6928c2" + ], + "version": "==1.13.1" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "click": { + "hashes": [ + "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", + "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" + ], + "version": "==7.0" + }, + "cryptography": { + "hashes": [ + "sha256:02079a6addc7b5140ba0825f542c0869ff4df9a69c360e339ecead5baefa843c", + "sha256:1df22371fbf2004c6f64e927668734070a8953362cd8370ddd336774d6743595", + "sha256:369d2346db5934345787451504853ad9d342d7f721ae82d098083e1f49a582ad", + "sha256:3cda1f0ed8747339bbdf71b9f38ca74c7b592f24f65cdb3ab3765e4b02871651", + "sha256:44ff04138935882fef7c686878e1c8fd80a723161ad6a98da31e14b7553170c2", + "sha256:4b1030728872c59687badcca1e225a9103440e467c17d6d1730ab3d2d64bfeff", + "sha256:58363dbd966afb4f89b3b11dfb8ff200058fbc3b947507675c19ceb46104b48d", + "sha256:6ec280fb24d27e3d97aa731e16207d58bd8ae94ef6eab97249a2afe4ba643d42", + "sha256:7270a6c29199adc1297776937a05b59720e8a782531f1f122f2eb8467f9aab4d", + "sha256:73fd30c57fa2d0a1d7a49c561c40c2f79c7d6c374cc7750e9ac7c99176f6428e", + "sha256:7f09806ed4fbea8f51585231ba742b58cbcfbfe823ea197d8c89a5e433c7e912", + "sha256:90df0cc93e1f8d2fba8365fb59a858f51a11a394d64dbf3ef844f783844cc793", + "sha256:971221ed40f058f5662a604bd1ae6e4521d84e6cad0b7b170564cc34169c8f13", + "sha256:a518c153a2b5ed6b8cc03f7ae79d5ffad7315ad4569b2d5333a13c38d64bd8d7", + "sha256:b0de590a8b0979649ebeef8bb9f54394d3a41f66c5584fff4220901739b6b2f0", + "sha256:b43f53f29816ba1db8525f006fa6f49292e9b029554b3eb56a189a70f2a40879", + "sha256:d31402aad60ed889c7e57934a03477b572a03af7794fa8fb1780f21ea8f6551f", + "sha256:de96157ec73458a7f14e3d26f17f8128c959084931e8997b9e655a39c8fde9f9", + "sha256:df6b4dca2e11865e6cfbfb708e800efb18370f5a46fd601d3755bc7f85b3a8a2", + "sha256:ecadccc7ba52193963c0475ac9f6fa28ac01e01349a2ca48509667ef41ffd2cf", + "sha256:fb81c17e0ebe3358486cd8cc3ad78adbae58af12fc2bf2bc0bb84e8090fa5ce8" + ], + "version": "==2.8" + }, + "etcd3-wrapper": { + "editable": true, + "git": "https://code.ungleich.ch/ungleich-public/etcd3_wrapper.git", + "ref": "76fb0bdf797199e9ea161dad1d004eea9b4520f8" + }, + "flask": { + "hashes": [ + "sha256:13f9f196f330c7c2c5d7a5cf91af894110ca0215ac051b5844701f2bfd934d52", + "sha256:45eb5a6fd193d6cf7e0cf5d8a5b31f83d5faae0293695626f539a823e93b13f6" + ], + "index": "pypi", + "version": "==1.1.1" + }, + "flask-restful": { + "hashes": [ + "sha256:ecd620c5cc29f663627f99e04f17d1f16d095c83dc1d618426e2ad68b03092f8", + "sha256:f8240ec12349afe8df1db168ea7c336c4e5b0271a36982bff7394f93275f2ca9" + ], + "index": "pypi", + "version": "==0.3.7" + }, + "grpcio": { + "hashes": [ + "sha256:01cb705eafba1108e2a947ba0457da4f6a1e8142c729fc61702b5fdd11009eb1", + "sha256:0b5a79e29f167d3cd06faad6b15babbc2661066daaacf79373c3a8e67ca1fca1", + "sha256:1097a61a0e97b3580642e6e1460a3a1f1ba1815e2a70d6057173bcc495417076", + "sha256:13970e665a4ec4cec7d067d7d3504a0398c657d91d26c581144ad9044e429c9a", + "sha256:1557817cea6e0b87fad2a3e20da385170efb03a313db164e8078955add2dfa1b", + "sha256:1b0fb036a2f9dd93d9a35c57c26420eeb4b571fcb14b51cddf5b1e73ea5d882b", + "sha256:24d9e58d08e8cd545d8a3247a18654aff0e5e60414701696a8098fbb0d792b75", + "sha256:2c38b586163d2b91567fe5e6d9e7798f792012365adc838a64b66b22dce3f4d4", + "sha256:2df3ab4348507de60e1cbf75196403df1b9b4c4d4dc5bd11ac4eb63c46f691c7", + "sha256:32f70f7c90454ea568b868af2e96616743718d9233d23f62407e98caed81dfbf", + "sha256:3af2a49d576820045c9c880ff29a5a96d020fe31b35d248519bfc6ccb8be4eac", + "sha256:4ff7d63800a63db031ebac6a6f581ae84877c959401c24c28f2cc51fd36c47ad", + "sha256:502aaa8be56f0ae69cda66bc27e1fb5531ceaa27ca515ec3c34f6178b1297180", + "sha256:55358ce3ec283222e435f7dbc6603521438458f3c65f7c1cb33b8dabf56d70d8", + "sha256:5583b01c67f85fa64a2c3fb085e5517c88b9c1500a2cce12d473cd99d0ed2e49", + "sha256:58d9a5557d3eb7b734a3cea8b16c891099a522b3953a45a30bd4c034f75fc913", + "sha256:5911f042c4ab177757eec5bcb4e2e9a2e823d888835d24577321bf55f02938fa", + "sha256:5e16ea922f4e5017c04fd94e2639b1006e03097e9dd0cbb7a1c852af3ea8bf2e", + "sha256:656e19d3f1b9050ee01b457f92838a9679d7cf84c995f708780f44484048705e", + "sha256:6a1435449a82008c451c7e1a82a834387b9108f9a8d27910f86e7c482f5568e9", + "sha256:6ff02ca6cbed0ddb76e93ba0f8beb6a8c77d83a84eb7cafe2ae3399a8b9d69ea", + "sha256:76de68f60102f333bf4817f38e81ecbee68b850f5a5da9f355235e948ac40981", + "sha256:7c6d7ddd50fc6548ea1dfe09c62509c4f95b8b40082287747be05aa8feb15ee2", + "sha256:836b9d29507de729129e363276fe7c7d6a34c7961e0f155787025552b15d22c0", + "sha256:869242b2baf8a888a4fe0548f86abc47cb4b48bdfd76ae62d6456e939c202e65", + "sha256:8954b24bd08641d906ee50b2d638efc76df893fbd0913149b80484fd0eac40c9", + "sha256:8cdea65d1abb2e698420db8daf20c8d272fbd9d96a51b26a713c1c76f237d181", + "sha256:90161840b4fe9636f91ed0d3ea1e7e615e488cbea4e77594c889e5f3d7a776db", + "sha256:90fb6316b4d7d36700c40db4335902b78dcae13b5466673c21fd3b08a3c1b0c6", + "sha256:91b34f58db2611c9a93ecf751028f97fba1f06e65f49b38f272f6aa5d2977331", + "sha256:9474944a96a33eb8734fa8dc5805403d57973a3526204a5e1c1780d02e0572b6", + "sha256:9a36275db2a4774ac16c6822e7af816ee048071d5030b4c035fd53942b361935", + "sha256:9cbe26e2976b994c5f7c2d35a63354674d6ca0ce62f5b513f078bf63c1745229", + "sha256:9eaeabb3c0eecd6ddd0c16767fd12d130e2cebb8c2618f959a278b1ff336ddc3", + "sha256:a2bc7e10ebcf4be503ae427f9887e75c0cc24e88ce467a8e6eaca6bd2862406e", + "sha256:a5b42e6292ba51b8e67e09fc256963ba4ca9c04026de004d2fe59cc17e3c3776", + "sha256:bd6ec1233c86c0b9bb5d03ec30dbe3ffbfa53335790320d99a7ae9018c5450f2", + "sha256:bef57530816af54d66b1f4c70a8f851f320cb6f84d4b5a0b422b0e9811ea4e59", + "sha256:c146a63eaadc6589b732780061f3c94cd0574388d372baccbb3c1597a9ebdb7a", + "sha256:c2efd3b130dc639d615b6f58980e1bfd1b177ad821f30827afa5001aa30ddd48", + "sha256:c888b18f7392e6cc79a33a803e7ebd7890ac3318f571fca6b356526f35b53b12", + "sha256:ca30721fda297ae22f16bc37aa7ed244970ddfdcb98247570cdd26daaad4665e", + "sha256:cf5f5340dd682ab034baa52f423a0f91326489c262ac9617fa06309ec05880e9", + "sha256:d0726aa0d9b57c56985db5952e90fb1033a317074f2877db5307cdd6eede1564", + "sha256:df442945b2dd6f8ae0e20b403e0fd4548cd5c2aad69200047cc3251257b78f65", + "sha256:e08e758c31919d167c0867539bd3b2441629ef00aa595e3ea2b635273659f40a", + "sha256:e4864339deeeaefaad34dd3a432ee618a039fca28efb292949c855e00878203c", + "sha256:f4cd049cb94d9f517b1cab5668a3b345968beba093bc79a637e671000b3540ec" + ], + "version": "==1.24.3" + }, + "idna": { + "hashes": [ + "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", + "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" + ], + "version": "==2.8" + }, + "itsdangerous": { + "hashes": [ + "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", + "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" + ], + "version": "==1.1.0" + }, + "jinja2": { + "hashes": [ + "sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f", + "sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de" + ], + "version": "==2.10.3" + }, + "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" + }, + "paramiko": { + "hashes": [ + "sha256:99f0179bdc176281d21961a003ffdb2ec369daac1a1007241f53374e376576cf", + "sha256:f4b2edfa0d226b70bd4ca31ea7e389325990283da23465d572ed1f70a7583041" + ], + "version": "==2.6.0" + }, + "protobuf": { + "hashes": [ + "sha256:125713564d8cfed7610e52444c9769b8dcb0b55e25cc7841f2290ee7bc86636f", + "sha256:1accdb7a47e51503be64d9a57543964ba674edac103215576399d2d0e34eac77", + "sha256:27003d12d4f68e3cbea9eb67427cab3bfddd47ff90670cb367fcd7a3a89b9657", + "sha256:3264f3c431a631b0b31e9db2ae8c927b79fc1a7b1b06b31e8e5bcf2af91fe896", + "sha256:3c5ab0f5c71ca5af27143e60613729e3488bb45f6d3f143dc918a20af8bab0bf", + "sha256:45dcf8758873e3f69feab075e5f3177270739f146255225474ee0b90429adef6", + "sha256:56a77d61a91186cc5676d8e11b36a5feb513873e4ae88d2ee5cf530d52bbcd3b", + "sha256:5984e4947bbcef5bd849d6244aec507d31786f2dd3344139adc1489fb403b300", + "sha256:6b0441da73796dd00821763bb4119674eaf252776beb50ae3883bed179a60b2a", + "sha256:6f6677c5ade94d4fe75a912926d6796d5c71a2a90c2aeefe0d6f211d75c74789", + "sha256:84a825a9418d7196e2acc48f8746cf1ee75877ed2f30433ab92a133f3eaf8fbe", + "sha256:b842c34fe043ccf78b4a6cf1019d7b80113707d68c88842d061fa2b8fb6ddedc", + "sha256:ca33d2f09dae149a1dcf942d2d825ebb06343b77b437198c9e2ef115cf5d5bc1", + "sha256:db83b5c12c0cd30150bb568e6feb2435c49ce4e68fe2d7b903113f0e221e58fe", + "sha256:f50f3b1c5c1c1334ca7ce9cad5992f098f460ffd6388a3cabad10b66c2006b09", + "sha256:f99f127909731cafb841c52f9216e447d3e4afb99b17bebfad327a75aee206de" + ], + "version": "==3.10.0" + }, + "pycparser": { + "hashes": [ + "sha256:9d97450dc26e1d2581c18881d8d1c0a92e84c9ac074961e3dc66e70d745a0643", + "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" + ], + "version": "==2.19" + }, + "pynacl": { + "hashes": [ + "sha256:05c26f93964373fc0abe332676cb6735f0ecad27711035b9472751faa8521255", + "sha256:0c6100edd16fefd1557da078c7a31e7b7d7a52ce39fdca2bec29d4f7b6e7600c", + "sha256:0d0a8171a68edf51add1e73d2159c4bc19fc0718e79dec51166e940856c2f28e", + "sha256:1c780712b206317a746ace34c209b8c29dbfd841dfbc02aa27f2084dd3db77ae", + "sha256:2424c8b9f41aa65bbdbd7a64e73a7450ebb4aa9ddedc6a081e7afcc4c97f7621", + "sha256:2d23c04e8d709444220557ae48ed01f3f1086439f12dbf11976e849a4926db56", + "sha256:30f36a9c70450c7878053fa1344aca0145fd47d845270b43a7ee9192a051bf39", + "sha256:37aa336a317209f1bb099ad177fef0da45be36a2aa664507c5d72015f956c310", + "sha256:4943decfc5b905748f0756fdd99d4f9498d7064815c4cf3643820c9028b711d1", + "sha256:53126cd91356342dcae7e209f840212a58dcf1177ad52c1d938d428eebc9fee5", + "sha256:57ef38a65056e7800859e5ba9e6091053cd06e1038983016effaffe0efcd594a", + "sha256:5bd61e9b44c543016ce1f6aef48606280e45f892a928ca7068fba30021e9b786", + "sha256:6482d3017a0c0327a49dddc8bd1074cc730d45db2ccb09c3bac1f8f32d1eb61b", + "sha256:7d3ce02c0784b7cbcc771a2da6ea51f87e8716004512493a2b69016326301c3b", + "sha256:a14e499c0f5955dcc3991f785f3f8e2130ed504fa3a7f44009ff458ad6bdd17f", + "sha256:a39f54ccbcd2757d1d63b0ec00a00980c0b382c62865b61a505163943624ab20", + "sha256:aabb0c5232910a20eec8563503c153a8e78bbf5459490c49ab31f6adf3f3a415", + "sha256:bd4ecb473a96ad0f90c20acba4f0bf0df91a4e03a1f4dd6a4bdc9ca75aa3a715", + "sha256:bf459128feb543cfca16a95f8da31e2e65e4c5257d2f3dfa8c0c1031139c9c92", + "sha256:e2da3c13307eac601f3de04887624939aca8ee3c9488a0bb0eca4fb9401fc6b1", + "sha256:f67814c38162f4deb31f68d590771a29d5ae3b1bd64b75cf232308e5c74777e0" + ], + "version": "==1.3.0" + }, + "pyotp": { + "hashes": [ + "sha256:c88f37fd47541a580b744b42136f387cdad481b560ef410c0d85c957eb2a2bc0", + "sha256:fc537e8acd985c5cbf51e11b7d53c42276fee017a73aec7c07380695671ca1a1" + ], + "index": "pypi", + "version": "==2.3.0" + }, + "python-decouple": { + "hashes": [ + "sha256:1317df14b43efee4337a4aa02914bf004f010cd56d6c4bd894e6474ec8c4fe2d" + ], + "index": "pypi", + "version": "==3.1" + }, + "python-etcd3": { + "editable": true, + "git": "https://github.com/kragniz/python-etcd3.git", + "ref": "247e3952d0b47324091a36ace3ad9717469fb6b9" + }, + "pytz": { + "hashes": [ + "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d", + "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be" + ], + "version": "==2019.3" + }, + "requests": { + "hashes": [ + "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", + "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" + ], + "index": "pypi", + "version": "==2.22.0" + }, + "six": { + "hashes": [ + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + ], + "version": "==1.12.0" + }, + "sshtunnel": { + "hashes": [ + "sha256:c813fdcda8e81c3936ffeac47cb69cfb2d1f5e77ad0de656c6dab56aeebd9249" + ], + "index": "pypi", + "version": "==0.1.5" + }, + "tenacity": { + "hashes": [ + "sha256:6a7511a59145c2e319b7d04ddd93c12d48cc3d3c8fa42c2846d33a620ee91f57", + "sha256:a4eb168dbf55ed2cae27e7c6b2bd48ab54dabaf294177d998330cf59f294c112" + ], + "version": "==5.1.1" + }, + "ucloud-common": { + "editable": true, + "git": "https://code.ungleich.ch/ucloud/ucloud_common.git", + "ref": "9f229eae27f9007e9c6c1021d3d5b12452863763" + }, + "urllib3": { + "hashes": [ + "sha256:3de946ffbed6e6746608990594d08faac602528ac7015ac28d33cee6a45b7398", + "sha256:9a107b99a5393caf59c7aa3c1249c16e6879447533d0887f4336dde834c7be86" + ], + "version": "==1.25.6" + }, + "werkzeug": { + "hashes": [ + "sha256:7280924747b5733b246fe23972186c6b348f9ae29724135a6dfc1e53cea433e7", + "sha256:e5f4a1f98b52b18a93da705a7458e55afb26f32bff83ff5d19189f92462d65c4" + ], + "version": "==0.16.0" + } + }, + "develop": { + "astroid": { + "hashes": [ + "sha256:6560e1e1749f68c64a4b5dee4e091fce798d2f0d84ebe638cf0e0585a343acf4", + "sha256:b65db1bbaac9f9f4d190199bb8680af6f6f84fd3769a5ea883df8a91fe68b4c4" + ], + "version": "==2.2.5" + }, + "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" + ], + "version": "==1.8.2" + }, + "docopt": { + "hashes": [ + "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491" + ], + "version": "==0.6.2" + }, + "docutils": { + "hashes": [ + "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0", + "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827", + "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99" + ], + "version": "==0.15.2" + }, + "dodgy": { + "hashes": [ + "sha256:65e13cf878d7aff129f1461c13cb5fd1bb6dfe66bb5327e09379c3877763280c" + ], + "version": "==0.1.9" + }, + "frosted": { + "hashes": [ + "sha256:c6a30ad502ea373f6fe4cafbcd896ece66948406b04365d14a3eb764cc529b07", + "sha256:d1e5d2b43a064b33c289b9a986a7425fd9a36bed8f519ca430ac7a0915e32b51" + ], + "version": "==1.4.1" + }, + "idna": { + "hashes": [ + "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", + "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" + ], + "version": "==2.8" + }, + "isort": { + "hashes": [ + "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", + "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd" + ], + "version": "==4.3.21" + }, + "lazy-object-proxy": { + "hashes": [ + "sha256:02b260c8deb80db09325b99edf62ae344ce9bc64d68b7a634410b8e9a568edbf", + "sha256:18f9c401083a4ba6e162355873f906315332ea7035803d0fd8166051e3d402e3", + "sha256:1f2c6209a8917c525c1e2b55a716135ca4658a3042b5122d4e3413a4030c26ce", + "sha256:2f06d97f0ca0f414f6b707c974aaf8829c2292c1c497642f63824119d770226f", + "sha256:616c94f8176808f4018b39f9638080ed86f96b55370b5a9463b2ee5c926f6c5f", + "sha256:63b91e30ef47ef68a30f0c3c278fbfe9822319c15f34b7538a829515b84ca2a0", + "sha256:77b454f03860b844f758c5d5c6e5f18d27de899a3db367f4af06bec2e6013a8e", + "sha256:83fe27ba321e4cfac466178606147d3c0aa18e8087507caec78ed5a966a64905", + "sha256:84742532d39f72df959d237912344d8a1764c2d03fe58beba96a87bfa11a76d8", + "sha256:874ebf3caaf55a020aeb08acead813baf5a305927a71ce88c9377970fe7ad3c2", + "sha256:9f5caf2c7436d44f3cec97c2fa7791f8a675170badbfa86e1992ca1b84c37009", + "sha256:a0c8758d01fcdfe7ae8e4b4017b13552efa7f1197dd7358dc9da0576f9d0328a", + "sha256:a4def978d9d28cda2d960c279318d46b327632686d82b4917516c36d4c274512", + "sha256:ad4f4be843dace866af5fc142509e9b9817ca0c59342fdb176ab6ad552c927f5", + "sha256:ae33dd198f772f714420c5ab698ff05ff900150486c648d29951e9c70694338e", + "sha256:b4a2b782b8a8c5522ad35c93e04d60e2ba7f7dcb9271ec8e8c3e08239be6c7b4", + "sha256:c462eb33f6abca3b34cdedbe84d761f31a60b814e173b98ede3c81bb48967c4f", + "sha256:fd135b8d35dfdcdb984828c84d695937e58cc5f49e1c854eb311c4d6aa03f4f1" + ], + "version": "==1.4.2" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "mock": { + "hashes": [ + "sha256:83657d894c90d5681d62155c82bda9c1187827525880eda8ff5df4ec813437c3", + "sha256:d157e52d4e5b938c550f39eb2fd15610db062441a9c2747d3dbfa9298211d0f8" + ], + "version": "==3.0.5" + }, + "mypy": { + "hashes": [ + "sha256:1521c186a3d200c399bd5573c828ea2db1362af7209b2adb1bb8532cea2fb36f", + "sha256:31a046ab040a84a0fc38bc93694876398e62bc9f35eca8ccbf6418b7297f4c00", + "sha256:3b1a411909c84b2ae9b8283b58b48541654b918e8513c20a400bb946aa9111ae", + "sha256:48c8bc99380575deb39f5d3400ebb6a8a1cb5cc669bbba4d3bb30f904e0a0e7d", + "sha256:540c9caa57a22d0d5d3c69047cc9dd0094d49782603eb03069821b41f9e970e9", + "sha256:672e418425d957e276c291930a3921b4a6413204f53fe7c37cad7bc57b9a3391", + "sha256:6ed3b9b3fdc7193ea7aca6f3c20549b377a56f28769783a8f27191903a54170f", + "sha256:9371290aa2cad5ad133e4cdc43892778efd13293406f7340b9ffe99d5ec7c1d9", + "sha256:ace6ac1d0f87d4072f05b5468a084a45b4eda970e4d26704f201e06d47ab2990", + "sha256:b428f883d2b3fe1d052c630642cc6afddd07d5cd7873da948644508be3b9d4a7", + "sha256:d5bf0e6ec8ba346a2cf35cb55bf4adfddbc6b6576fcc9e10863daa523e418dbb", + "sha256:d7574e283f83c08501607586b3167728c58e8442947e027d2d4c7dcd6d82f453", + "sha256:dc889c84241a857c263a2b1cd1121507db7d5b5f5e87e77147097230f374d10b", + "sha256:f4748697b349f373002656bf32fede706a0e713d67bfdcf04edf39b1f61d46eb" + ], + "version": "==0.740" + }, + "mypy-extensions": { + "hashes": [ + "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", + "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" + ], + "version": "==0.4.3" + }, + "nose": { + "hashes": [ + "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac", + "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a", + "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98" + ], + "version": "==1.3.7" + }, + "pep8-naming": { + "hashes": [ + "sha256:1b419fa45b68b61cd8c5daf4e0c96d28915ad14d3d5f35fcc1e7e95324a33a2e", + "sha256:4eedfd4c4b05e48796f74f5d8628c068ff788b9c2b08471ad408007fc6450e5a" + ], + "version": "==0.4.1" + }, + "pies": { + "hashes": [ + "sha256:79a652dddc64c6fa42c7dfe9686ae7b1d856391094b873e2f52fcd0bd662c102", + "sha256:e8a76923ce0e0f605240901983fe492814a65d3d803efe3013a0e1815b75e4e9" + ], + "version": "==2.6.7" + }, + "prospector": { + "extras": [ + "with_everything" + ], + "hashes": [ + "sha256:aba551e53dc1a5a432afa67385eaa81d7b4cf4c162dc1a4d0ee00b3a0712ad90" + ], + "index": "pypi", + "version": "==1.1.7" + }, + "pycodestyle": { + "hashes": [ + "sha256:cbc619d09254895b0d12c2c691e237b2e91e9b2ecf5e84c26b35400f93dcfb83", + "sha256:cbfca99bd594a10f674d0cd97a3d802a1fdef635d4361e1a2658de47ed261e3a" + ], + "version": "==2.4.0" + }, + "pydocstyle": { + "hashes": [ + "sha256:04c84e034ebb56eb6396c820442b8c4499ac5eb94a3bda88951ac3dc519b6058", + "sha256:66aff87ffe34b1e49bff2dd03a88ce6843be2f3346b0c9814410d34987fbab59" + ], + "version": "==4.0.1" + }, + "pyflakes": { + "hashes": [ + "sha256:08bd6a50edf8cffa9fa09a463063c425ecaaf10d1eb0335a7e8b1401aef89e6f", + "sha256:8d616a382f243dbf19b54743f280b80198be0bca3a5396f1d2e1fca6223e8805" + ], + "version": "==1.6.0" + }, + "pylint": { + "hashes": [ + "sha256:5d77031694a5fb97ea95e828c8d10fc770a1df6eb3906067aaed42201a8a6a09", + "sha256:723e3db49555abaf9bf79dc474c6b9e2935ad82230b10c1138a71ea41ac0fff1" + ], + "version": "==2.3.1" + }, + "pylint-celery": { + "hashes": [ + "sha256:41e32094e7408d15c044178ea828dd524beedbdbe6f83f712c5e35bde1de4beb" + ], + "version": "==0.3" + }, + "pylint-django": { + "hashes": [ + "sha256:75c69d1ec2275918c37f175976da20e2f1e1e62e067098a685cd263ffa833dfd", + "sha256:c7cb6384ea7b33ea77052a5ae07358c10d377807390ef27b2e6ff997303fadb7" + ], + "version": "==2.0.10" + }, + "pylint-flask": { + "hashes": [ + "sha256:f4d97de2216bf7bfce07c9c08b166e978fe9f2725de2a50a9845a97de7e31517" + ], + "version": "==0.6" + }, + "pylint-plugin-utils": { + "hashes": [ + "sha256:2f30510e1c46edf268d3a195b2849bd98a1b9433229bb2ba63b8d776e1fc4d0a", + "sha256:57625dcca20140f43731311cd8fd879318bf45a8b0fd17020717a8781714a25a" + ], + "version": "==0.6" + }, + "pyroma": { + "hashes": [ + "sha256:54d332f540d4828bc5672b75ccf9e12d4b2f72a42a4f304bcec1c73565aecc26", + "sha256:6b94feb609e1896579302f0836ef2fad3f17e0557e3ddcd0d76206cd3e366d27" + ], + "version": "==2.5" + }, + "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" + }, + "requests": { + "hashes": [ + "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", + "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" + ], + "index": "pypi", + "version": "==2.22.0" + }, + "requirements-detector": { + "hashes": [ + "sha256:9fbc4b24e8b7c3663aff32e3eba34596848c6b91bd425079b386973bd8d08931" + ], + "version": "==0.6" + }, + "setoptconf": { + "hashes": [ + "sha256:5b0b5d8e0077713f5d5152d4f63be6f048d9a1bb66be15d089a11c898c3cf49c" + ], + "version": "==0.2.0" + }, + "six": { + "hashes": [ + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + ], + "version": "==1.12.0" + }, + "snowballstemmer": { + "hashes": [ + "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", + "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" + ], + "version": "==2.0.0" + }, + "typed-ast": { + "hashes": [ + "sha256:1170afa46a3799e18b4c977777ce137bb53c7485379d9706af8a59f2ea1aa161", + "sha256:18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", + "sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", + "sha256:2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", + "sha256:354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", + "sha256:48e5b1e71f25cfdef98b013263a88d7145879fbb2d5185f2a0c79fa7ebbeae47", + "sha256:4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", + "sha256:630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", + "sha256:66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", + "sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", + "sha256:7954560051331d003b4e2b3eb822d9dd2e376fa4f6d98fee32f452f52dd6ebb2", + "sha256:838997f4310012cf2e1ad3803bce2f3402e9ffb71ded61b5ee22617b3a7f6b6e", + "sha256:95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", + "sha256:bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", + "sha256:cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", + "sha256:d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", + "sha256:d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", + "sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", + "sha256:fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66", + "sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12" + ], + "markers": "implementation_name == 'cpython'", + "version": "==1.4.0" + }, + "typing-extensions": { + "hashes": [ + "sha256:2ed632b30bb54fc3941c382decfd0ee4148f5c591651c9272473fea2c6397d95", + "sha256:b1edbbf0652660e32ae780ac9433f4231e7339c7f9a8057d0f042fcbcea49b87", + "sha256:d8179012ec2c620d3791ca6fe2bf7979d979acdbef1fca0bc56b37411db682ed" + ], + "version": "==3.7.4" + }, + "urllib3": { + "hashes": [ + "sha256:3de946ffbed6e6746608990594d08faac602528ac7015ac28d33cee6a45b7398", + "sha256:9a107b99a5393caf59c7aa3c1249c16e6879447533d0887f4336dde834c7be86" + ], + "version": "==1.25.6" + }, + "vulture": { + "hashes": [ + "sha256:17be5f6a7c88ea43f2619f80338af7407275ee46a24000abe2570e59ca44b3d0", + "sha256:23d837cf619c3bb75f87bc498c79cd4f27f0c54031ca88a9e05606c9dd627fef" + ], + "version": "==0.24" + }, + "wrapt": { + "hashes": [ + "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1" + ], + "version": "==1.11.2" + } + } +} diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..09f6205 --- /dev/null +++ b/TODO.md @@ -0,0 +1,6 @@ +# TODO + +- Check for `etcd3.exceptions.ConnectionFailedError` when calling some etcd operation to + avoid crashing whole application +- Throw KeyError instead of returning None when some key is not found in etcd +- Specify image format when using qemu-img when creating virtual machine \ No newline at end of file diff --git a/api/README.md b/api/README.md new file mode 100755 index 0000000..e28d676 --- /dev/null +++ b/api/README.md @@ -0,0 +1,12 @@ +# ucloud-api +[![Project Status: WIP – Initial development is in progress, but there has not yet been a stable, usable release suitable for the public.](https://www.repostatus.org/badges/latest/wip.svg)](https://www.repostatus.org/#wip) + +## Installation + +**Make sure you have Python >= 3.5 and Pipenv installed.** + +1. Clone the repository and `cd` into it. +2. Run the following commands + - `pipenv install` + - `pipenv shell` + - `python main.py` diff --git a/api/common_fields.py b/api/common_fields.py new file mode 100755 index 0000000..390fe40 --- /dev/null +++ b/api/common_fields.py @@ -0,0 +1,48 @@ +import os + +from config import etcd_client as client +from config import VM_PREFIX + + +class Field: + def __init__(self, _name, _type, _value=None): + self.name = _name + self.value = _value + self.type = _type + self.__errors = [] + + def validation(self): + return True + + def is_valid(self): + if self.value == KeyError: + self.add_error("'{}' field is a required field".format(self.name)) + else: + if not isinstance(self.value, self.type): + self.add_error("Incorrect Type for '{}' field".format(self.name)) + else: + self.validation() + + if self.__errors: + return False + return True + + def get_errors(self): + return self.__errors + + def add_error(self, error): + self.__errors.append(error) + + +class VmUUIDField(Field): + def __init__(self, data): + self.uuid = data.get("uuid", KeyError) + + super().__init__("uuid", str, self.uuid) + + self.validation = self.vm_uuid_validation + + def vm_uuid_validation(self): + r = client.get(os.path.join(VM_PREFIX, self.uuid)) + if not r: + self.add_error("VM with uuid {} does not exists".format(self.uuid)) diff --git a/api/config.py b/api/config.py new file mode 100644 index 0000000..4ae1b66 --- /dev/null +++ b/api/config.py @@ -0,0 +1,32 @@ +import logging + +from etcd3_wrapper import Etcd3Wrapper +from decouple import config + + +from ucloud_common.vm import VmPool +from ucloud_common.host import HostPool +from ucloud_common.request import RequestPool + +logging.basicConfig( + level=logging.DEBUG, + filename="log.txt", + filemode="a", + format="%(asctime)s: %(levelname)s - %(message)s", + datefmt="%d-%b-%y %H:%M:%S", +) + + +WITHOUT_CEPH = config("WITHOUT_CEPH", False, cast=bool) +VM_PREFIX = config("VM_PREFIX") +HOST_PREFIX = config("HOST_PREFIX") +REQUEST_PREFIX = config("REQUEST_PREFIX") +FILE_PREFIX = config("FILE_PREFIX") +IMAGE_PREFIX = config("IMAGE_PREFIX") +IMAGE_STORE_PREFIX = config("IMAGE_STORE_PREFIX") + +etcd_client = Etcd3Wrapper(host=config("ETCD_URL")) + +VM_POOL = VmPool(etcd_client, VM_PREFIX) +HOST_POOL = HostPool(etcd_client, HOST_PREFIX) +REQUEST_POOL = RequestPool(etcd_client, REQUEST_PREFIX) diff --git a/api/create_image_store.py b/api/create_image_store.py new file mode 100755 index 0000000..796cc43 --- /dev/null +++ b/api/create_image_store.py @@ -0,0 +1,17 @@ +import json +import os + +from uuid import uuid4 + +from config import etcd_client as client +from config import IMAGE_STORE_PREFIX + +data = { + "is_public": True, + "type": "ceph", + "name": "images", + "description": "first ever public image-store", + "attributes": {"list": [], "key": [], "pool": "images"}, +} + +client.put(os.path.join(IMAGE_STORE_PREFIX, uuid4().hex), json.dumps(data)) diff --git a/api/helper.py b/api/helper.py new file mode 100755 index 0000000..67a0379 --- /dev/null +++ b/api/helper.py @@ -0,0 +1,70 @@ +import binascii +import requests + +from decouple import config +from pyotp import TOTP +from config import VM_POOL + + +def check_otp(name, realm, token): + try: + data = { + "auth_name": config("AUTH_NAME", ""), + "auth_token": TOTP(config("AUTH_SEED", "")).now(), + "auth_realm": config("AUTH_REALM", ""), + "name": name, + "realm": realm, + "token": token, + } + except binascii.Error: + return 400 + + response = requests.get( + "{OTP_SERVER}{OTP_VERIFY_ENDPOINT}".format( + OTP_SERVER=config("OTP_SERVER", ""), + OTP_VERIFY_ENDPOINT=config("OTP_VERIFY_ENDPOINT", "verify"), + ), + json=data, + ) + return response.status_code + + +def resolve_vm_name(name, owner): + """Return UUID of Virtual Machine of name == name and owner == owner + + Input: name of vm, owner of vm. + Output: uuid of vm if found otherwise None + """ + result = next( + filter( + lambda vm: vm.value["owner"] == owner and vm.value["name"] == name, + VM_POOL.vms, + ), + None, + ) + if result: + return result.key.split("/")[-1] + + return None + +import random + +def random_bytes(num=6): + return [random.randrange(256) for _ in range(num)] + +def generate_mac(uaa=False, multicast=False, oui=None, separator=':', byte_fmt='%02x'): + mac = random_bytes() + if oui: + if type(oui) == str: + oui = [int(chunk) for chunk in oui.split(separator)] + mac = oui + random_bytes(num=6-len(oui)) + else: + if multicast: + mac[0] |= 1 # set bit 0 + else: + mac[0] &= ~1 # clear bit 0 + if uaa: + mac[0] &= ~(1 << 1) # clear bit 1 + else: + mac[0] |= 1 << 1 # set bit 1 + return separator.join(byte_fmt % b for b in mac) diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..e8082d5 --- /dev/null +++ b/api/main.py @@ -0,0 +1,380 @@ +import json +import subprocess +import os +from uuid import uuid4 + +from flask import Flask, request +from flask_restful import Resource, Api + +from ucloud_common.vm import VMStatus +from ucloud_common.request import RequestEntry, RequestType + +from helper import generate_mac + +from config import ( + etcd_client, + WITHOUT_CEPH, + VM_PREFIX, + HOST_PREFIX, + FILE_PREFIX, + IMAGE_PREFIX, + logging, + REQUEST_POOL, + VM_POOL, + HOST_POOL, +) +from schemas import ( + CreateVMSchema, + VMStatusSchema, + CreateImageSchema, + VmActionSchema, + OTPSchema, + CreateHostSchema, + VmMigrationSchema, + AddSSHSchema, + RemoveSSHSchema, + GetSSHSchema +) + +app = Flask(__name__) +api = Api(app) + + +class CreateVM(Resource): + @staticmethod + def post(): + data = request.json + print(data) + validator = CreateVMSchema(data) + if validator.is_valid(): + vm_uuid = uuid4().hex + vm_key = os.path.join(VM_PREFIX, vm_uuid) + specs = { + 'cpu': validator.specs['cpu'], + 'ram': validator.specs['ram'], + 'os-ssd': validator.specs['os-ssd'], + 'hdd': validator.specs['hdd'] + } + + vm_entry = { + "name": data["vm_name"], + "owner": data["name"], + "owner_realm": data["realm"], + "specs": specs, + "hostname": "", + "status": "", + "image_uuid": data["image_uuid"], + "log": [], + "vnc_socket": "", + "mac": str(generate_mac()), + "metadata": { + "ssh-keys": [] + } + } + etcd_client.put(vm_key, vm_entry, value_in_json=True) + + # Create ScheduleVM Request + r = RequestEntry.from_scratch(type=RequestType.ScheduleVM, uuid=vm_uuid) + REQUEST_POOL.put(r) + + return {"message": "VM Creation Queued"}, 200 + return validator.get_errors(), 400 + + +class VmStatus(Resource): + @staticmethod + def get(): + data = request.json + validator = VMStatusSchema(data) + if validator.is_valid(): + vm = VM_POOL.get(os.path.join(VM_PREFIX, data["uuid"])) + return json.dumps(str(vm)) + else: + return validator.get_errors(), 400 + + +class CreateImage(Resource): + @staticmethod + def post(): + data = request.json + validator = CreateImageSchema(data) + if validator.is_valid(): + file_entry = etcd_client.get(os.path.join(FILE_PREFIX, data["uuid"])) + file_entry_value = json.loads(file_entry.value) + + image_entry_json = { + "status": "TO_BE_CREATED", + "owner": file_entry_value["owner"], + "filename": file_entry_value["filename"], + "name": data["name"], + "store_name": data["image_store"], + "visibility": "public", + } + etcd_client.put( + os.path.join(IMAGE_PREFIX, data["uuid"]), json.dumps(image_entry_json) + ) + + return {"message": "Image successfully created"} + return validator.get_errors(), 400 + + +class ListPublicImages(Resource): + @staticmethod + def get(): + images = etcd_client.get_prefix(IMAGE_PREFIX) + r = {} + for image in images: + r[image.key.split("/")[-1]] = json.loads(image.value) + return r, 200 + + +class VMAction(Resource): + @staticmethod + def post(): + data = request.json + validator = VmActionSchema(data) + + if validator.is_valid(): + vm_entry = VM_POOL.get(os.path.join(VM_PREFIX, data["uuid"])) + action = data["action"] + + if action == "start": + vm_entry.status = VMStatus.requested_start + VM_POOL.put(vm_entry) + action = "schedule" + + if action == "delete" and vm_entry.hostname == "": + try: + path_without_protocol = vm_entry.path[vm_entry.path.find(":") + 1 :] + + if WITHOUT_CEPH: + command_to_delete = [ + "rm", "-rf", + os.path.join("/var/vm", vm_entry.uuid), + ] + else: + command_to_delete = ["rbd", "rm", path_without_protocol] + + subprocess.check_output(command_to_delete, stderr=subprocess.PIPE) + except subprocess.CalledProcessError as e: + if "No such file" in e.stderr.decode("utf-8"): + etcd_client.client.delete(vm_entry.key) + return {"message": "VM successfully deleted"} + else: + logging.exception(e) + return {"message": "Some error occurred while deleting VM"} + else: + etcd_client.client.delete(vm_entry.key) + return {"message": "VM successfully deleted"} + + r = RequestEntry.from_scratch( + type="{}VM".format(action.title()), + uuid=data["uuid"], + hostname=vm_entry.hostname, + ) + REQUEST_POOL.put(r) + return {"message": "VM {} Queued".format(action.title())}, 200 + else: + return validator.get_errors(), 400 + + +class VMMigration(Resource): + @staticmethod + def post(): + data = request.json + validator = VmMigrationSchema(data) + + if validator.is_valid(): + vm = VM_POOL.get(data["uuid"]) + + r = RequestEntry.from_scratch( + type=RequestType.ScheduleVM, + uuid=vm.uuid, + destination=os.path.join(HOST_PREFIX, data["destination"]), + migration=True, + ) + REQUEST_POOL.put(r) + return {"message": "VM Migration Initialization Queued"}, 200 + else: + return validator.get_errors(), 400 + + +class ListUserVM(Resource): + @staticmethod + def get(): + data = request.json + validator = OTPSchema(data) + + if validator.is_valid(): + vms = etcd_client.get_prefix(VM_PREFIX, value_in_json=True) + return_vms = [] + user_vms = filter(lambda v: v.value["owner"] == data["name"], vms) + for vm in user_vms: + return_vms.append( + { + "name": vm.value["name"], + "vm_uuid": vm.key.split("/")[-1], + "specs": vm.value["specs"], + "status": vm.value["status"], + "hostname": vm.value["hostname"], + "mac": vm.value["mac"], + "vnc_socket": None + if vm.value.get("vnc_socket", None) is None + else vm.value["vnc_socket"], + } + ) + if return_vms: + return {"message": return_vms}, 200 + return {"message": "No VM found"}, 404 + + else: + return validator.get_errors(), 400 + + +class ListUserFiles(Resource): + @staticmethod + def get(): + data = request.json + validator = OTPSchema(data) + + if validator.is_valid(): + files = etcd_client.get_prefix(FILE_PREFIX, value_in_json=True) + return_files = [] + user_files = list(filter(lambda f: f.value["owner"] == data["name"], files)) + for file in user_files: + return_files.append( + { + "filename": file.value["filename"], + "uuid": file.key.split("/")[-1], + } + ) + return {"message": return_files}, 200 + else: + return validator.get_errors(), 400 + + +class CreateHost(Resource): + @staticmethod + def post(): + data = request.json + validator = CreateHostSchema(data) + if validator.is_valid(): + host_key = os.path.join(HOST_PREFIX, uuid4().hex) + host_entry = { + "specs": data["specs"], + "hostname": data["hostname"], + "status": "DEAD", + "last_heartbeat": "", + } + etcd_client.put(host_key, host_entry, value_in_json=True) + + return {"message": "Host Created"}, 200 + + return validator.get_errors(), 400 + + +class ListHost(Resource): + @staticmethod + def get(): + hosts = HOST_POOL.hosts + r = { + host.key: { + "status": host.status, + "specs": host.specs, + "hostname": host.hostname, + } + for host in hosts + } + return r, 200 + + +class GetSSHKeys(Resource): + @staticmethod + def get(): + data = request.json + validator = GetSSHSchema(data) + if validator.is_valid(): + if not validator.key_name.value: + + # {user_prefix}/{realm}/{name}/key/ + etcd_key = os.path.join(USER_PREFIX, data["realm"], data["name"], "key") + etcd_entry = etcd_client.get_prefix(etcd_key, value_in_json=True) + + keys = {key.key.split("/")[-1]: key.value for key in etcd_entry} + return {"keys": keys} + else: + + # {user_prefix}/{realm}/{name}/key/{key_name} + etcd_key = os.path.join(USER_PREFIX, data["realm"], data["name"], + "key", data["key_name"]) + etcd_entry = etcd_client.get(etcd_key, value_in_json=True) + + if etcd_entry: + return {"keys": {etcd_entry.key.split("/")[-1]: etcd_entry.value}} + else: + return {"keys": {}} + else: + return validator.get_errors(), 400 + + +class AddSSHKey(Resource): + @staticmethod + def post(): + data = request.json + validator = AddSSHSchema(data) + if validator.is_valid(): + + # {user_prefix}/{realm}/{name}/key/{key_name} + etcd_key = os.path.join(USER_PREFIX, data["realm"], data["name"], + "key", data["key_name"]) + etcd_entry = etcd_client.get(etcd_key, value_in_json=True) + if etcd_entry: + return {"message": "Key with name '{}' already exists".format(data["key_name"])} + else: + # Key Not Found. It implies user' haven't added any key yet. + etcd_client.put(etcd_key, data["key"], value_in_json=True) + return {"message": "Key added successfully"} + else: + return validator.get_errors(), 400 + + +class RemoveSSHKey(Resource): + @staticmethod + def get(): + data = request.json + validator = RemoveSSHSchema(data) + if validator.is_valid(): + + # {user_prefix}/{realm}/{name}/key/{key_name} + etcd_key = os.path.join(USER_PREFIX, data["realm"], data["name"], + "key", data["key_name"]) + etcd_entry = etcd_client.get(etcd_key, value_in_json=True) + if etcd_entry: + etcd_client.client.delete(etcd_key) + return {"message": "Key successfully removed."} + else: + return {"message": "No Key with name '{}' Exists at all.".format(data["key_name"])} + else: + return validator.get_errors(), 400 + +api.add_resource(CreateVM, "/vm/create") +api.add_resource(VmStatus, "/vm/status") + +api.add_resource(VMAction, "/vm/action") +api.add_resource(VMMigration, "/vm/migrate") + +api.add_resource(CreateImage, "/image/create") +api.add_resource(ListPublicImages, "/image/list-public") + +api.add_resource(ListUserVM, "/user/vms") +api.add_resource(ListUserFiles, "/user/files") + +api.add_resource(AddSSHKey, "/user/add-ssh") +api.add_resource(RemoveSSHKey, "/user/remove-ssh") +api.add_resource(GetSSHKeys, "/user/get-ssh") + +api.add_resource(CreateHost, "/host/create") +api.add_resource(ListHost, "/host/list") + +if __name__ == "__main__": + app.run(host="::", debug=True) diff --git a/api/schemas.py b/api/schemas.py new file mode 100755 index 0000000..8f366bf --- /dev/null +++ b/api/schemas.py @@ -0,0 +1,415 @@ +""" +This module contain classes thats validates and intercept/modify +data coming from ucloud-cli (user) + +It was primarily developed as an alternative to argument parser +of Flask_Restful which is going to be deprecated. I also tried +marshmallow for that purpose but it was an overkill (because it +do validation + serialization + deserialization) and little +inflexible for our purpose. +""" + +# TODO: Fix error message when user's mentioned VM (referred by name) +# does not exists. +# +# Currently, it says uuid is a required field. + +import json +import os +import bitmath + +from ucloud_common.host import HostPool, HostStatus +from ucloud_common.vm import VmPool, VMStatus + +from common_fields import Field, VmUUIDField +from helper import check_otp, resolve_vm_name +from config import etcd_client as client +from config import (HOST_PREFIX, VM_PREFIX, IMAGE_PREFIX, + FILE_PREFIX, IMAGE_STORE_PREFIX) + +HOST_POOL = HostPool(client, HOST_PREFIX) +VM_POOL = VmPool(client, VM_PREFIX) + + +class BaseSchema: + def __init__(self, data, fields=None): + _ = data # suppress linter warning + self.__errors = [] + if fields is None: + self.fields = [] + else: + self.fields = fields + + def validation(self): + # custom validation is optional + return True + + def is_valid(self): + for field in self.fields: + field.is_valid() + self.add_field_errors(field) + + for parent in self.__class__.__bases__: + try: + parent.validation(self) + except AttributeError: + pass + if not self.__errors: + self.validation() + + if self.__errors: + return False + return True + + def get_errors(self): + return {"message": self.__errors} + + def add_field_errors(self, field: Field): + self.__errors += field.get_errors() + + def add_error(self, error): + self.__errors.append(error) + + +class OTPSchema(BaseSchema): + def __init__(self, data: dict, fields=None): + self.name = Field("name", str, data.get("name", KeyError)) + self.realm = Field("realm", str, data.get("realm", KeyError)) + self.token = Field("token", str, data.get("token", KeyError)) + + _fields = [self.name, self.realm, self.token] + if fields: + _fields += fields + super().__init__(data=data, fields=_fields) + + def validation(self): + print(self.name.value, self.realm.value, self.token.value) + if check_otp(self.name.value, self.realm.value, self.token.value) != 200: + self.add_error("Wrong Credentials") + + +########################## Image Operations ############################################### + + +class CreateImageSchema(BaseSchema): + def __init__(self, data): + # Fields + self.uuid = Field("uuid", str, data.get("uuid", KeyError)) + self.name = Field("name", str, data.get("name", KeyError)) + self.image_store = Field("image_store", str, data.get("image_store", KeyError)) + + # Validations + self.uuid.validation = self.file_uuid_validation + self.image_store.validation = self.image_store_name_validation + + # All Fields + fields = [self.uuid, self.name, self.image_store] + super().__init__(data, fields) + + def file_uuid_validation(self): + file_entry = client.get(os.path.join(FILE_PREFIX, self.uuid.value)) + if file_entry is None: + self.add_error( + "Image File with uuid '{}' Not Found".format(self.uuid.value) + ) + + def image_store_name_validation(self): + image_stores = list(client.get_prefix(IMAGE_STORE_PREFIX)) + + image_store = next( + filter( + lambda s: json.loads(s.value)["name"] == self.image_store.value, + image_stores, + ), + None, + ) + if not image_store: + self.add_error("Store '{}' does not exists".format(self.image_store.value)) + + +# Host Operations + +class CreateHostSchema(OTPSchema): + def __init__(self, data): + self.parsed_specs = {} + # Fields + self.specs = Field("specs", dict, data.get("specs", KeyError)) + self.hostname = Field("hostname", str, data.get("hostname", KeyError)) + + # Validation + self.specs.validation = self.specs_validation + + fields = [self.hostname, self.specs] + + super().__init__(data=data, fields=fields) + + def specs_validation(self): + ALLOWED_BASE = 10 + + _cpu = self.specs.value.get('cpu', KeyError) + _ram = self.specs.value.get('ram', KeyError) + _os_ssd = self.specs.value.get('os-ssd', KeyError) + _hdd = self.specs.value.get('hdd', KeyError) + + if KeyError in [_cpu, _ram, _os_ssd, _hdd]: + self.add_error("You must specify CPU, RAM and OS-SSD in your specs") + return None + try: + parsed_ram = bitmath.parse_string_unsafe(_ram) + parsed_os_ssd = bitmath.parse_string_unsafe(_os_ssd) + + if parsed_ram.base != ALLOWED_BASE: + self.add_error("Your specified RAM is not in correct units") + if parsed_os_ssd.base != ALLOWED_BASE: + self.add_error("Your specified OS-SSD is not in correct units") + + if _cpu < 1: + self.add_error("CPU must be atleast 1") + + if parsed_ram < bitmath.GB(1): + self.add_error("RAM must be atleast 1 GB") + + if parsed_os_ssd < bitmath.GB(10): + self.add_error("OS-SSD must be atleast 10 GB") + + parsed_hdd = [] + for hdd in _hdd: + _parsed_hdd = bitmath.parse_string_unsafe(hdd) + if _parsed_hdd.base != ALLOWED_BASE: + self.add_error("Your specified HDD is not in correct units") + break + else: + parsed_hdd.append(str(_parsed_hdd)) + + except ValueError: + # TODO: Find some good error message + self.add_error("Specs are not correct.") + else: + if self.get_errors(): + self.specs = { + 'cpu': _cpu, + 'ram': str(parsed_ram), + 'os-ssd': str(parsed_os_ssd), + 'hdd': parsed_hdd + } + + def validation(self): + if self.realm.value != "ungleich-admin": + self.add_error("Invalid Credentials/Insufficient Permission") + + +# VM Operations + + +class CreateVMSchema(OTPSchema): + def __init__(self, data): + self.parsed_specs = {} + # Fields + self.specs = Field("specs", dict, data.get("specs", KeyError)) + self.vm_name = Field("vm_name", str, data.get("vm_name", KeyError)) + self.image_uuid = Field("image_uuid", str, data.get("image_uuid", KeyError)) + + # Validation + self.image_uuid.validation = self.image_uuid_validation + self.vm_name.validation = self.vm_name_validation + self.specs.validation = self.specs_validation + + fields = [self.vm_name, self.image_uuid, self.specs] + + super().__init__(data=data, fields=fields) + + def image_uuid_validation(self): + images = client.get_prefix(IMAGE_PREFIX) + + if self.image_uuid.value not in [i.key.split("/")[-1] for i in images]: + self.add_error("Image UUID not valid") + + def vm_name_validation(self): + if resolve_vm_name(name=self.vm_name.value, owner=self.name.value): + self.add_error( + 'VM with same name "{}" already exists'.format(self.vm_name.value) + ) + + def specs_validation(self): + ALLOWED_BASE = 10 + + _cpu = self.specs.value.get('cpu', KeyError) + _ram = self.specs.value.get('ram', KeyError) + _os_ssd = self.specs.value.get('os-ssd', KeyError) + _hdd = self.specs.value.get('hdd', KeyError) + + if KeyError in [_cpu, _ram, _os_ssd, _hdd]: + self.add_error("You must specify CPU, RAM and OS-SSD in your specs") + return None + try: + parsed_ram = bitmath.parse_string_unsafe(_ram) + parsed_os_ssd = bitmath.parse_string_unsafe(_os_ssd) + + if parsed_ram.base != ALLOWED_BASE: + self.add_error("Your specified RAM is not in correct units") + if parsed_os_ssd.base != ALLOWED_BASE: + self.add_error("Your specified OS-SSD is not in correct units") + + if _cpu < 1: + self.add_error("CPU must be atleast 1") + + if parsed_ram < bitmath.GB(1): + self.add_error("RAM must be atleast 1 GB") + + if parsed_os_ssd < bitmath.GB(1): + self.add_error("OS-SSD must be atleast 1 GB") + + parsed_hdd = [] + for hdd in _hdd: + _parsed_hdd = bitmath.parse_string_unsafe(hdd) + if _parsed_hdd.base != ALLOWED_BASE: + self.add_error("Your specified HDD is not in correct units") + break + else: + parsed_hdd.append(str(_parsed_hdd)) + + except ValueError: + # TODO: Find some good error message + self.add_error("Specs are not correct.") + else: + if self.get_errors(): + self.specs = { + 'cpu': _cpu, + 'ram': str(parsed_ram), + 'os-ssd': str(parsed_os_ssd), + 'hdd': parsed_hdd + } + + +class VMStatusSchema(OTPSchema): + def __init__(self, data): + data["uuid"] = ( + resolve_vm_name( + name=data.get("vm_name", None), + owner=(data.get("in_support_of", None) or data.get("name", None)), + ) + or KeyError + ) + self.uuid = VmUUIDField(data) + + fields = [self.uuid] + + super().__init__(data, fields) + + def validation(self): + vm = VM_POOL.get(self.uuid.value) + if not ( + vm.value["owner"] == self.name.value or self.realm.value == "ungleich-admin" + ): + self.add_error("Invalid User") + + +class VmActionSchema(OTPSchema): + def __init__(self, data): + data["uuid"] = ( + resolve_vm_name( + name=data.get("vm_name", None), + owner=(data.get("in_support_of", None) or data.get("name", None)), + ) + or KeyError + ) + self.uuid = VmUUIDField(data) + self.action = Field("action", str, data.get("action", KeyError)) + + self.action.validation = self.action_validation + + _fields = [self.uuid, self.action] + + super().__init__(data=data, fields=_fields) + + def action_validation(self): + allowed_actions = ["start", "stop", "delete"] + if self.action.value not in allowed_actions: + self.add_error( + "Invalid Action. Allowed Actions are {}".format(allowed_actions) + ) + + def validation(self): + vm = VM_POOL.get(self.uuid.value) + if not ( + vm.value["owner"] == self.name.value or self.realm.value == "ungleich-admin" + ): + self.add_error("Invalid User") + + if ( + self.action.value == "start" + and vm.status == VMStatus.running + and vm.hostname != "" + ): + self.add_error("VM Already Running") + + if self.action.value == "stop": + if vm.status == VMStatus.stopped: + self.add_error("VM Already Stopped") + elif vm.status != VMStatus.running: + self.add_error("Cannot stop non-running VM") + + +class VmMigrationSchema(OTPSchema): + def __init__(self, data): + data["uuid"] = ( + resolve_vm_name( + name=data.get("vm_name", None), + owner=(data.get("in_support_of", None) or data.get("name", None)), + ) + or KeyError + ) + + self.uuid = VmUUIDField(data) + self.destination = Field("destination", str, data.get("destination", KeyError)) + + self.destination.validation = self.destination_validation + + fields = [self.destination] + super().__init__(data=data, fields=fields) + + def destination_validation(self): + host_key = self.destination.value + host = HOST_POOL.get(host_key) + if not host: + self.add_error("No Such Host ({}) exists".format(self.destination.value)) + elif host.status != HostStatus.alive: + self.add_error("Destination Host is dead") + + def validation(self): + vm = VM_POOL.get(self.uuid.value) + if not ( + vm.value["owner"] == self.name.value or self.realm.value == "ungleich-admin" + ): + self.add_error("Invalid User") + + if vm.status != VMStatus.running: + self.add_error("Can't migrate non-running VM") + + if vm.hostname == os.path.join(HOST_PREFIX, self.destination.value): + self.add_error("Destination host couldn't be same as Source Host") + + +class AddSSHSchema(OTPSchema): + def __init__(self, data): + self.key_name = Field("key_name", str, data.get("key_name", KeyError)) + self.key = Field("key", str, data.get("key_name", KeyError)) + + fields = [self.key_name, self.key] + super().__init__(data=data, fields=fields) + + +class RemoveSSHSchema(OTPSchema): + def __init__(self, data): + self.key_name = Field("key_name", str, data.get("key_name", KeyError)) + + fields = [self.key_name] + super().__init__(data=data, fields=fields) + + +class GetSSHSchema(OTPSchema): + def __init__(self, data): + self.key_name = Field("key_name", str, data.get("key_name", None)) + + fields = [self.key_name] + super().__init__(data=data, fields=fields) \ No newline at end of file diff --git a/filescanner/main.py b/filescanner/main.py new file mode 100755 index 0000000..84ac53e --- /dev/null +++ b/filescanner/main.py @@ -0,0 +1,109 @@ +import os +import glob +import pathlib +import time +import hashlib +import subprocess as sp + +from decouple import config +from etcd3_wrapper import Etcd3Wrapper +from uuid import uuid4 + + + +def getxattr(file, attr): + try: + attr = "user." + attr + value = sp.check_output(['getfattr', file, + '--name', attr, + '--only-values', + '--absolute-names']) + value = value.decode("utf-8") + except sp.CalledProcessError: + value = None + + return value + +def setxattr(file, attr, value): + attr = "user." + attr + sp.check_output(['setfattr', file, + '--name', attr, + '--value', str(value)]) + + +def sha512sum(filename): + _sum = hashlib.sha512() + buffer_size = 2**16 + + with open(filename, "rb") as f: + while True: + data = f.read(buffer_size) + if not data: + break + _sum.update(data) + + return _sum.hexdigest() + + +try: + sp.check_output(['which', 'getfattr']) + sp.check_output(['which', 'setfattr']) +except Exception as e: + print(e) + print('Make sure you have getfattr and setfattr available') + exit(1) + + +BASE_DIR = config("BASE_DIR") + +FILE_PREFIX = config("FILE_PREFIX") + +etcd_client = Etcd3Wrapper(host=config("ETCD_URL")) + +# Recursively Get All Files and Folder below BASE_DIR +files = glob.glob("{}/**".format(BASE_DIR), recursive=True) + +# Retain only Files +files = list(filter(os.path.isfile, files)) + +untracked_files = list( + filter(lambda f: not bool(getxattr(f, "user.utracked")), files) +) + +tracked_files = list( + filter(lambda f: f not in untracked_files, files) +) +for file in untracked_files: + file_id = uuid4() + + # Get Username + owner = pathlib.Path(file).parts[3] + # Get Creation Date of File + # Here, we are assuming that ctime is creation time + # which is mostly not true. + creation_date = time.ctime(os.stat(file).st_ctime) + + # Get File Size + size = os.path.getsize(file) + + # Compute sha512 sum + sha_sum = sha512sum(file) + + # File Path excluding base and username + file_path = pathlib.Path(file).parts[4:] + file_path = os.path.join(*file_path) + + # Create Entry + entry_key = os.path.join(FILE_PREFIX, str(file_id)) + entry_value = { + "filename": file_path, + "owner": owner, + "sha512sum": sha_sum, + "creation_date": creation_date, + "size": size + } + + print("Tracking {}".format(file)) + # Insert Entry + etcd_client.put(entry_key, entry_value, value_in_json=True) + setxattr(file, "user.utracked", True) diff --git a/host/config.py b/host/config.py new file mode 100755 index 0000000..4191828 --- /dev/null +++ b/host/config.py @@ -0,0 +1,32 @@ +import logging + +from etcd3_wrapper import Etcd3Wrapper +from ucloud_common.vm import VmPool +from ucloud_common.host import HostPool +from ucloud_common.request import RequestPool +from decouple import config + +WITHOUT_CEPH = config("WITHOUT_CEPH", False, cast=bool) + +logging.basicConfig( + level=logging.DEBUG, + filename="log.txt", + filemode="a", + format="%(asctime)s: %(levelname)s - %(message)s", + datefmt="%d-%b-%y %H:%M:%S", +) + +etcd_client = Etcd3Wrapper(host=config("ETCD_URL")) + +HOST_PREFIX = config("HOST_PREFIX") +VM_PREFIX = config("VM_PREFIX") +REQUEST_PREFIX = config("REQUEST_PREFIX") +VM_DIR = config("VM_DIR") +IMAGE_DIR = config("IMAGE_DIR") + + +host_pool = HostPool(etcd_client, HOST_PREFIX) +vm_pool = VmPool(etcd_client, VM_PREFIX) +request_pool = RequestPool(etcd_client, REQUEST_PREFIX) + +running_vms = [] diff --git a/host/main.py b/host/main.py new file mode 100755 index 0000000..8fe73c9 --- /dev/null +++ b/host/main.py @@ -0,0 +1,137 @@ +import argparse +import threading +import time +import os +import sys +import virtualmachine + +from ucloud_common.host import HostEntry +from ucloud_common.request import RequestEntry, RequestType + +from config import (vm_pool, host_pool, request_pool, + etcd_client, logging, running_vms, + REQUEST_PREFIX, WITHOUT_CEPH) + + +def update_heartbeat(host: HostEntry): + while True: + host.update_heartbeat() + host_pool.put(host) + time.sleep(10) + + logging.info("Updated last heartbeat time %s", host.last_heartbeat) + + +def maintenance(host): + # To capture vm running according to running_vms list + + # This is to capture successful migration of a VM. + # Suppose, this host is running "vm1" and user initiated + # request to migrate this "vm1" to some other host. On, + # successful migration the destination host would set + # the vm hostname to itself. Thus, we are checking + # whether this host vm is successfully migrated. If yes + # then we shutdown "vm1" on this host. + + for running_vm in running_vms: + with vm_pool.get_put(running_vm.key) as vm_entry: + if vm_entry.hostname != host.key and not vm_entry.in_migration: + running_vm.handle.shutdown() + vm_entry.add_log("VM on source host shutdown.") + # To check vm running according to etcd entries + alleged_running_vms = vm_pool.by_status("RUNNING", vm_pool.by_host(host.key)) + + for vm_entry in alleged_running_vms: + _vm = virtualmachine.get_vm(running_vms, vm_entry.key) + + # Whether, the allegedly running vm is in our + # running_vms list or not if it is said to be + # running on this host but it is not then we + # need to shut it down + + # This is to capture poweroff/shutdown of a VM + # initiated by user inside VM. OR crash of VM by some + # user running process + if (_vm and not _vm.handle.is_running()) or not _vm: + vm_entry.add_log("""{} is not running but is said to be running. + So, shutting it down and declare it killed""".format(vm_entry.key)) + vm_entry.declare_killed() + vm_pool.put(vm_entry) + if _vm: + running_vms.remove(_vm) + + +def main(): + argparser = argparse.ArgumentParser() + argparser.add_argument("hostname", help="Name of this host. e.g /v1/host/1") + args = argparser.parse_args() + + host = host_pool.get(args.hostname) + if not host: + print("No Such Host") + exit(1) + + if WITHOUT_CEPH and not os.path.isdir("/var/vm"): + print("You have set WITHOUT_CEPH to True. So, the /var/vm must exists. But, it don't") + sys.exit(1) + + + logging.info("%s Session Started %s", '*' * 5, '*' * 5) + + # It is seen that under heavy load, timeout event doesn't come + # in a predictive manner (which is intentional because we give + # higher priority to customer's requests) which delays heart + # beat update which in turn misunderstood by scheduler that the + # host is dead when it is actually alive. So, to ensure that we + # update the heart beat in a predictive manner we start Heart + # beat updating mechanism in separated thread + + heartbeat_updating_thread = threading.Thread(target=update_heartbeat, args=(host,)) + try: + heartbeat_updating_thread.start() + except Exception as e: + logging.info("No Need To Go Further. Our heartbeat updating mechanism is not working") + logging.exception(e) + exit(-1) + + for events_iterator in [ + etcd_client.get_prefix(REQUEST_PREFIX, value_in_json=True), + etcd_client.watch_prefix(REQUEST_PREFIX, timeout=10, value_in_json=True), + ]: + for request_event in events_iterator: + request_event = RequestEntry(request_event) + + if request_event.type == "TIMEOUT": + logging.info("Timeout Event") + maintenance(host) + continue + + # If the event is directed toward me OR I am destination of a InitVMMigration + if (request_event.hostname == host.key or request_event.destination == host.key): + logging.debug("EVENT: %s", request_event) + + request_pool.client.client.delete(request_event.key) + vm_entry = vm_pool.get(request_event.uuid) + + if vm_entry: + if request_event.type == RequestType.StartVM: + virtualmachine.start(vm_entry) + + elif request_event.type == RequestType.StopVM: + virtualmachine.stop(vm_entry) + + elif request_event.type == RequestType.DeleteVM: + virtualmachine.delete(vm_entry) + + elif request_event.type == RequestType.InitVMMigration: + virtualmachine.init_migration(vm_entry, host.key) + + elif request_event.type == RequestType.TransferVM: + virtualmachine.transfer(request_event) + else: + logging.info("VM Entry missing") + + logging.info("Running VMs %s", running_vms) + + +main() diff --git a/host/qmp/__init__.py b/host/qmp/__init__.py new file mode 100755 index 0000000..97669bc --- /dev/null +++ b/host/qmp/__init__.py @@ -0,0 +1,534 @@ +# QEMU library +# +# Copyright (C) 2015-2016 Red Hat Inc. +# Copyright (C) 2012 IBM Corp. +# +# Authors: +# Fam Zheng +# +# This work is licensed under the terms of the GNU GPL, version 2. See +# the COPYING file in the top-level directory. +# +# Based on qmp.py. +# + +import errno +import logging +import os +import subprocess +import re +import shutil +import socket +import tempfile + +from . import qmp + + +LOG = logging.getLogger(__name__) + +# Mapping host architecture to any additional architectures it can +# support which often includes its 32 bit cousin. +ADDITIONAL_ARCHES = { + "x86_64" : "i386", + "aarch64" : "armhf" +} + +def kvm_available(target_arch=None): + host_arch = os.uname()[4] + if target_arch and target_arch != host_arch: + if target_arch != ADDITIONAL_ARCHES.get(host_arch): + return False + return os.access("/dev/kvm", os.R_OK | os.W_OK) + + +class QEMUMachineError(Exception): + """ + Exception called when an error in QEMUMachine happens. + """ + + +class QEMUMachineAddDeviceError(QEMUMachineError): + """ + Exception raised when a request to add a device can not be fulfilled + + The failures are caused by limitations, lack of information or conflicting + requests on the QEMUMachine methods. This exception does not represent + failures reported by the QEMU binary itself. + """ + +class MonitorResponseError(qmp.QMPError): + """ + Represents erroneous QMP monitor reply + """ + def __init__(self, reply): + try: + desc = reply["error"]["desc"] + except KeyError: + desc = reply + super(MonitorResponseError, self).__init__(desc) + self.reply = reply + + +class QEMUMachine(object): + """ + A QEMU VM + + Use this object as a context manager to ensure the QEMU process terminates:: + + with VM(binary) as vm: + ... + # vm is guaranteed to be shut down here + """ + + def __init__(self, binary, args=None, wrapper=None, name=None, + test_dir="/var/tmp", monitor_address=None, + socket_scm_helper=None): + ''' + Initialize a QEMUMachine + + @param binary: path to the qemu binary + @param args: list of extra arguments + @param wrapper: list of arguments used as prefix to qemu binary + @param name: prefix for socket and log file names (default: qemu-PID) + @param test_dir: where to create socket and log file + @param monitor_address: address for QMP monitor + @param socket_scm_helper: helper program, required for send_fd_scm() + @note: Qemu process is not started until launch() is used. + ''' + if args is None: + args = [] + if wrapper is None: + wrapper = [] + if name is None: + name = "qemu-%d" % os.getpid() + self._name = name + self._monitor_address = monitor_address + self._vm_monitor = None + self._qemu_log_path = None + self._qemu_log_file = None + self._popen = None + self._binary = binary + self._args = list(args) # Force copy args in case we modify them + self._wrapper = wrapper + self._events = [] + self._iolog = None + self._socket_scm_helper = socket_scm_helper + self._qmp = None + self._qemu_full_args = None + self._test_dir = test_dir + self._temp_dir = None + self._launched = False + self._machine = None + self._console_set = False + self._console_device_type = None + self._console_address = None + self._console_socket = None + + # just in case logging wasn't configured by the main script: + logging.basicConfig(level=logging.DEBUG) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.shutdown() + return False + + # This can be used to add an unused monitor instance. + def add_monitor_null(self): + self._args.append('-monitor') + self._args.append('null') + + def add_fd(self, fd, fdset, opaque, opts=''): + """ + Pass a file descriptor to the VM + """ + options = ['fd=%d' % fd, + 'set=%d' % fdset, + 'opaque=%s' % opaque] + if opts: + options.append(opts) + + # This did not exist before 3.4, but since then it is + # mandatory for our purpose + if hasattr(os, 'set_inheritable'): + os.set_inheritable(fd, True) + + self._args.append('-add-fd') + self._args.append(','.join(options)) + return self + + # Exactly one of fd and file_path must be given. + # (If it is file_path, the helper will open that file and pass its + # own fd) + def send_fd_scm(self, fd=None, file_path=None): + # In iotest.py, the qmp should always use unix socket. + assert self._qmp.is_scm_available() + if self._socket_scm_helper is None: + raise QEMUMachineError("No path to socket_scm_helper set") + if not os.path.exists(self._socket_scm_helper): + raise QEMUMachineError("%s does not exist" % + self._socket_scm_helper) + + # This did not exist before 3.4, but since then it is + # mandatory for our purpose + if hasattr(os, 'set_inheritable'): + os.set_inheritable(self._qmp.get_sock_fd(), True) + if fd is not None: + os.set_inheritable(fd, True) + + fd_param = ["%s" % self._socket_scm_helper, + "%d" % self._qmp.get_sock_fd()] + + if file_path is not None: + assert fd is None + fd_param.append(file_path) + else: + assert fd is not None + fd_param.append(str(fd)) + + devnull = open(os.path.devnull, 'rb') + proc = subprocess.Popen(fd_param, stdin=devnull, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, close_fds=False) + output = proc.communicate()[0] + if output: + LOG.debug(output) + + return proc.returncode + + @staticmethod + def _remove_if_exists(path): + """ + Remove file object at path if it exists + """ + try: + os.remove(path) + except OSError as exception: + if exception.errno == errno.ENOENT: + return + raise + + def is_running(self): + return self._popen is not None and self._popen.poll() is None + + def exitcode(self): + if self._popen is None: + return None + return self._popen.poll() + + def get_pid(self): + if not self.is_running(): + return None + return self._popen.pid + + def _load_io_log(self): + if self._qemu_log_path is not None: + with open(self._qemu_log_path, "r") as iolog: + self._iolog = iolog.read() + + def _base_args(self): + if isinstance(self._monitor_address, tuple): + moncdev = "socket,id=mon,host=%s,port=%s" % ( + self._monitor_address[0], + self._monitor_address[1]) + else: + moncdev = 'socket,id=mon,path=%s' % self._vm_monitor + args = ['-chardev', moncdev, + '-mon', 'chardev=mon,mode=control'] + if self._machine is not None: + args.extend(['-machine', self._machine]) + if self._console_set: + self._console_address = os.path.join(self._temp_dir, + self._name + "-console.sock") + chardev = ('socket,id=console,path=%s,server,nowait' % + self._console_address) + args.extend(['-chardev', chardev]) + if self._console_device_type is None: + args.extend(['-serial', 'chardev:console']) + else: + device = '%s,chardev=console' % self._console_device_type + args.extend(['-device', device]) + return args + + def _pre_launch(self): + self._temp_dir = tempfile.mkdtemp(dir=self._test_dir) + if self._monitor_address is not None: + self._vm_monitor = self._monitor_address + else: + self._vm_monitor = os.path.join(self._temp_dir, + self._name + "-monitor.sock") + self._qemu_log_path = os.path.join(self._temp_dir, self._name + ".log") + self._qemu_log_file = open(self._qemu_log_path, 'wb') + + self._qmp = qmp.QEMUMonitorProtocol(self._vm_monitor, + server=True) + + def _post_launch(self): + self._qmp.accept() + + def _post_shutdown(self): + if self._qemu_log_file is not None: + self._qemu_log_file.close() + self._qemu_log_file = None + + self._qemu_log_path = None + + if self._console_socket is not None: + self._console_socket.close() + self._console_socket = None + + if self._temp_dir is not None: + shutil.rmtree(self._temp_dir) + self._temp_dir = None + + def launch(self): + """ + Launch the VM and make sure we cleanup and expose the + command line/output in case of exception + """ + + if self._launched: + raise QEMUMachineError('VM already launched') + + self._iolog = None + self._qemu_full_args = None + try: + self._launch() + self._launched = True + except: + self.shutdown() + + LOG.debug('Error launching VM') + if self._qemu_full_args: + LOG.debug('Command: %r', ' '.join(self._qemu_full_args)) + if self._iolog: + LOG.debug('Output: %r', self._iolog) + raise + + def _launch(self): + """ + Launch the VM and establish a QMP connection + """ + devnull = open(os.path.devnull, 'rb') + self._pre_launch() + self._qemu_full_args = (self._wrapper + [self._binary] + + self._base_args() + self._args) + LOG.debug('VM launch command: %r', ' '.join(self._qemu_full_args)) + self._popen = subprocess.Popen(self._qemu_full_args, + stdin=devnull, + stdout=self._qemu_log_file, + stderr=subprocess.STDOUT, + shell=False, + close_fds=False) + self._post_launch() + + def wait(self): + """ + Wait for the VM to power off + """ + self._popen.wait() + self._qmp.close() + self._load_io_log() + self._post_shutdown() + + def shutdown(self): + """ + Terminate the VM and clean up + """ + if self.is_running(): + try: + self._qmp.cmd('quit') + self._qmp.close() + except: + self._popen.kill() + self._popen.wait() + + self._load_io_log() + self._post_shutdown() + + exitcode = self.exitcode() + if exitcode is not None and exitcode < 0: + msg = 'qemu received signal %i: %s' + if self._qemu_full_args: + command = ' '.join(self._qemu_full_args) + else: + command = '' + LOG.warn(msg, -exitcode, command) + + self._launched = False + + def qmp(self, cmd, conv_keys=True, **args): + """ + Invoke a QMP command and return the response dict + """ + qmp_args = dict() + for key, value in args.items(): + if conv_keys: + qmp_args[key.replace('_', '-')] = value + else: + qmp_args[key] = value + + return self._qmp.cmd(cmd, args=qmp_args) + + def command(self, cmd, conv_keys=True, **args): + """ + Invoke a QMP command. + On success return the response dict. + On failure raise an exception. + """ + reply = self.qmp(cmd, conv_keys, **args) + if reply is None: + raise qmp.QMPError("Monitor is closed") + if "error" in reply: + raise MonitorResponseError(reply) + return reply["return"] + + def get_qmp_event(self, wait=False): + """ + Poll for one queued QMP events and return it + """ + if len(self._events) > 0: + return self._events.pop(0) + return self._qmp.pull_event(wait=wait) + + def get_qmp_events(self, wait=False): + """ + Poll for queued QMP events and return a list of dicts + """ + events = self._qmp.get_events(wait=wait) + events.extend(self._events) + del self._events[:] + self._qmp.clear_events() + return events + + @staticmethod + def event_match(event, match=None): + """ + Check if an event matches optional match criteria. + + The match criteria takes the form of a matching subdict. The event is + checked to be a superset of the subdict, recursively, with matching + values whenever the subdict values are not None. + + This has a limitation that you cannot explicitly check for None values. + + Examples, with the subdict queries on the left: + - None matches any object. + - {"foo": None} matches {"foo": {"bar": 1}} + - {"foo": None} matches {"foo": 5} + - {"foo": {"abc": None}} does not match {"foo": {"bar": 1}} + - {"foo": {"rab": 2}} matches {"foo": {"bar": 1, "rab": 2}} + """ + if match is None: + return True + + try: + for key in match: + if key in event: + if not QEMUMachine.event_match(event[key], match[key]): + return False + else: + return False + return True + except TypeError: + # either match or event wasn't iterable (not a dict) + return match == event + + def event_wait(self, name, timeout=60.0, match=None): + """ + event_wait waits for and returns a named event from QMP with a timeout. + + name: The event to wait for. + timeout: QEMUMonitorProtocol.pull_event timeout parameter. + match: Optional match criteria. See event_match for details. + """ + return self.events_wait([(name, match)], timeout) + + def events_wait(self, events, timeout=60.0): + """ + events_wait waits for and returns a named event from QMP with a timeout. + + events: a sequence of (name, match_criteria) tuples. + The match criteria are optional and may be None. + See event_match for details. + timeout: QEMUMonitorProtocol.pull_event timeout parameter. + """ + def _match(event): + for name, match in events: + if (event['event'] == name and + self.event_match(event, match)): + return True + return False + + # Search cached events + for event in self._events: + if _match(event): + self._events.remove(event) + return event + + # Poll for new events + while True: + event = self._qmp.pull_event(wait=timeout) + if _match(event): + return event + self._events.append(event) + + return None + + def get_log(self): + """ + After self.shutdown or failed qemu execution, this returns the output + of the qemu process. + """ + return self._iolog + + def add_args(self, *args): + """ + Adds to the list of extra arguments to be given to the QEMU binary + """ + self._args.extend(args) + + def set_machine(self, machine_type): + """ + Sets the machine type + + If set, the machine type will be added to the base arguments + of the resulting QEMU command line. + """ + self._machine = machine_type + + def set_console(self, device_type=None): + """ + Sets the device type for a console device + + If set, the console device and a backing character device will + be added to the base arguments of the resulting QEMU command + line. + + This is a convenience method that will either use the provided + device type, or default to a "-serial chardev:console" command + line argument. + + The actual setting of command line arguments will be be done at + machine launch time, as it depends on the temporary directory + to be created. + + @param device_type: the device type, such as "isa-serial". If + None is given (the default value) a "-serial + chardev:console" command line argument will + be used instead, resorting to the machine's + default device type. + """ + self._console_set = True + self._console_device_type = device_type + + @property + def console_socket(self): + """ + Returns a socket connected to the console + """ + if self._console_socket is None: + self._console_socket = socket.socket(socket.AF_UNIX, + socket.SOCK_STREAM) + self._console_socket.connect(self._console_address) + return self._console_socket diff --git a/host/qmp/__pycache__/__init__.cpython-37.pyc b/host/qmp/__pycache__/__init__.cpython-37.pyc new file mode 100755 index 0000000000000000000000000000000000000000..3f11efc235ae2bfba83332b074d903719ace3747 GIT binary patch literal 15086 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 Dl`yTW literal 0 HcmV?d00001 diff --git a/host/qmp/__pycache__/qmp.cpython-37.pyc b/host/qmp/__pycache__/qmp.cpython-37.pyc new file mode 100755 index 0000000000000000000000000000000000000000..e9f7c9443cd3bfc52fcc1b91eee75eab123a7230 GIT binary patch literal 8571 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 literal 0 HcmV?d00001 diff --git a/host/qmp/qmp.py b/host/qmp/qmp.py new file mode 100755 index 0000000..5c8cf6a --- /dev/null +++ b/host/qmp/qmp.py @@ -0,0 +1,256 @@ +# QEMU Monitor Protocol Python class +# +# Copyright (C) 2009, 2010 Red Hat Inc. +# +# Authors: +# Luiz Capitulino +# +# This work is licensed under the terms of the GNU GPL, version 2. See +# the COPYING file in the top-level directory. + +import json +import errno +import socket +import logging + + +class QMPError(Exception): + pass + + +class QMPConnectError(QMPError): + pass + + +class QMPCapabilitiesError(QMPError): + pass + + +class QMPTimeoutError(QMPError): + pass + + +class QEMUMonitorProtocol(object): + + #: Logger object for debugging messages + logger = logging.getLogger('QMP') + #: Socket's error class + error = socket.error + #: Socket's timeout + timeout = socket.timeout + + def __init__(self, address, server=False): + """ + Create a QEMUMonitorProtocol class. + + @param address: QEMU address, can be either a unix socket path (string) + or a tuple in the form ( address, port ) for a TCP + connection + @param server: server mode listens on the socket (bool) + @raise socket.error on socket connection errors + @note No connection is established, this is done by the connect() or + accept() methods + """ + self.__events = [] + self.__address = address + self.__sock = self.__get_sock() + self.__sockfile = None + if server: + self.__sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.__sock.bind(self.__address) + self.__sock.listen(1) + + def __get_sock(self): + if isinstance(self.__address, tuple): + family = socket.AF_INET + else: + family = socket.AF_UNIX + return socket.socket(family, socket.SOCK_STREAM) + + def __negotiate_capabilities(self): + greeting = self.__json_read() + if greeting is None or "QMP" not in greeting: + raise QMPConnectError + # Greeting seems ok, negotiate capabilities + resp = self.cmd('qmp_capabilities') + if "return" in resp: + return greeting + raise QMPCapabilitiesError + + def __json_read(self, only_event=False): + while True: + data = self.__sockfile.readline() + if not data: + return + resp = json.loads(data) + if 'event' in resp: + self.logger.debug("<<< %s", resp) + self.__events.append(resp) + if not only_event: + continue + return resp + + def __get_events(self, wait=False): + """ + Check for new events in the stream and cache them in __events. + + @param wait (bool): block until an event is available. + @param wait (float): If wait is a float, treat it as a timeout value. + + @raise QMPTimeoutError: If a timeout float is provided and the timeout + period elapses. + @raise QMPConnectError: If wait is True but no events could be + retrieved or if some other error occurred. + """ + + # Check for new events regardless and pull them into the cache: + self.__sock.setblocking(0) + try: + self.__json_read() + except socket.error as err: + if err[0] == errno.EAGAIN: + # No data available + pass + self.__sock.setblocking(1) + + # Wait for new events, if needed. + # if wait is 0.0, this means "no wait" and is also implicitly false. + if not self.__events and wait: + if isinstance(wait, float): + self.__sock.settimeout(wait) + try: + ret = self.__json_read(only_event=True) + except socket.timeout: + raise QMPTimeoutError("Timeout waiting for event") + except: + raise QMPConnectError("Error while reading from socket") + if ret is None: + raise QMPConnectError("Error while reading from socket") + self.__sock.settimeout(None) + + def connect(self, negotiate=True): + """ + Connect to the QMP Monitor and perform capabilities negotiation. + + @return QMP greeting dict + @raise socket.error on socket connection errors + @raise QMPConnectError if the greeting is not received + @raise QMPCapabilitiesError if fails to negotiate capabilities + """ + self.__sock.connect(self.__address) + self.__sockfile = self.__sock.makefile() + if negotiate: + return self.__negotiate_capabilities() + + def accept(self): + """ + Await connection from QMP Monitor and perform capabilities negotiation. + + @return QMP greeting dict + @raise socket.error on socket connection errors + @raise QMPConnectError if the greeting is not received + @raise QMPCapabilitiesError if fails to negotiate capabilities + """ + self.__sock.settimeout(15) + self.__sock, _ = self.__sock.accept() + self.__sockfile = self.__sock.makefile() + return self.__negotiate_capabilities() + + def cmd_obj(self, qmp_cmd): + """ + Send a QMP command to the QMP Monitor. + + @param qmp_cmd: QMP command to be sent as a Python dict + @return QMP response as a Python dict or None if the connection has + been closed + """ + self.logger.debug(">>> %s", qmp_cmd) + try: + self.__sock.sendall(json.dumps(qmp_cmd).encode('utf-8')) + except socket.error as err: + if err[0] == errno.EPIPE: + return + raise socket.error(err) + resp = self.__json_read() + self.logger.debug("<<< %s", resp) + return resp + + def cmd(self, name, args=None, cmd_id=None): + """ + Build a QMP command and send it to the QMP Monitor. + + @param name: command name (string) + @param args: command arguments (dict) + @param cmd_id: command id (dict, list, string or int) + """ + qmp_cmd = {'execute': name} + if args: + qmp_cmd['arguments'] = args + if cmd_id: + qmp_cmd['id'] = cmd_id + return self.cmd_obj(qmp_cmd) + + def command(self, cmd, **kwds): + """ + Build and send a QMP command to the monitor, report errors if any + """ + ret = self.cmd(cmd, kwds) + if "error" in ret: + raise Exception(ret['error']['desc']) + return ret['return'] + + def pull_event(self, wait=False): + """ + Pulls a single event. + + @param wait (bool): block until an event is available. + @param wait (float): If wait is a float, treat it as a timeout value. + + @raise QMPTimeoutError: If a timeout float is provided and the timeout + period elapses. + @raise QMPConnectError: If wait is True but no events could be + retrieved or if some other error occurred. + + @return The first available QMP event, or None. + """ + self.__get_events(wait) + + if self.__events: + return self.__events.pop(0) + return None + + def get_events(self, wait=False): + """ + Get a list of available QMP events. + + @param wait (bool): block until an event is available. + @param wait (float): If wait is a float, treat it as a timeout value. + + @raise QMPTimeoutError: If a timeout float is provided and the timeout + period elapses. + @raise QMPConnectError: If wait is True but no events could be + retrieved or if some other error occurred. + + @return The list of available QMP events. + """ + self.__get_events(wait) + return self.__events + + def clear_events(self): + """ + Clear current list of pending events. + """ + self.__events = [] + + def close(self): + self.__sock.close() + self.__sockfile.close() + + def settimeout(self, timeout): + self.__sock.settimeout(timeout) + + def get_sock_fd(self): + return self.__sock.fileno() + + def is_scm_available(self): + return self.__sock.family == socket.AF_UNIX diff --git a/host/virtualmachine.py b/host/virtualmachine.py new file mode 100755 index 0000000..f99ffd0 --- /dev/null +++ b/host/virtualmachine.py @@ -0,0 +1,310 @@ +# QEMU Manual +# https://qemu.weilnetz.de/doc/qemu-doc.html + +# For QEMU Monitor Protocol Commands Information, See +# https://qemu.weilnetz.de/doc/qemu-doc.html#pcsys_005fmonitor + +import errno +import os +import subprocess +import tempfile +import time + +from functools import wraps +from os.path import join +from typing import Union + +import bitmath +import sshtunnel +from decouple import config + +import qmp +from config import (WITHOUT_CEPH, VM_PREFIX, VM_DIR, IMAGE_DIR, + etcd_client, logging, request_pool, + running_vms, vm_pool) +from ucloud_common.helpers import get_ipv4_address +from ucloud_common.request import RequestEntry, RequestType +from ucloud_common.vm import VMEntry, VMStatus + + +class VM: + def __init__(self, key, handle, vnc_socket_file): + self.key = key # type: str + self.handle = handle # type: qmp.QEMUMachine + self.vnc_socket_file = vnc_socket_file # type: tempfile.NamedTemporaryFile + + def __repr__(self): + return "VM({})".format(self.key) + + +def get_start_command_args( + vm_entry, vnc_sock_filename: str, migration=False, migration_port=4444 +): + threads_per_core = 1 + vm_memory = int(bitmath.parse_string(vm_entry.specs["ram"]).to_MB()) + vm_cpus = int(vm_entry.specs["cpu"]) + vm_uuid = vm_entry.uuid + + if WITHOUT_CEPH: + command = "-drive file={},format=raw,if=virtio,cache=none".format( + os.path.join(VM_DIR, vm_uuid) + ) + else: + command = "-drive file=rbd:uservms/{},format=raw,if=virtio,cache=none".format( + vm_uuid + ) + + command += " -device virtio-rng-pci -vnc unix:{}".format(vnc_sock_filename) + command += " -m {} -smp cores={},threads={}".format( + vm_memory, vm_cpus, threads_per_core + ) + command += " -name {}".format(vm_uuid) + + if migration: + command += " -incoming tcp:0:{}".format(migration_port) + + command += " -nic tap,model=virtio,mac={}".format(vm_entry.mac) + return command.split(" ") + + +def create_vm_object(vm_entry, migration=False, migration_port=4444): + # NOTE: If migration suddenly stop working, having different + # VNC unix filename on source and destination host can + # be a possible cause of it. + + # REQUIREMENT: Use Unix Socket instead of TCP Port for VNC + vnc_sock_file = tempfile.NamedTemporaryFile() + + qemu_args = get_start_command_args( + vm_entry=vm_entry, + vnc_sock_filename=vnc_sock_file.name, + migration=migration, + migration_port=migration_port, + ) + qemu_machine = qmp.QEMUMachine("/usr/bin/qemu-system-x86_64", args=qemu_args) + return VM(vm_entry.key, qemu_machine, vnc_sock_file) + + +def get_vm(vm_list: list, vm_key) -> Union[VM, None]: + return next((vm for vm in vm_list if vm.key == vm_key), None) + + +def need_running_vm(func): + @wraps(func) + def wrapper(e): + vm = get_vm(running_vms, e.key) + if vm: + try: + status = vm.handle.command("query-status") + logging.debug("VM Status Check - %s", status) + except Exception as exception: + logging.info("%s failed - VM %s %s", func.__name__, e, exception) + else: + return func(e) + + return None + else: + logging.info("%s failed because VM %s is not running", func.__name__, e.key) + return None + + return wrapper + + +def create(vm_entry: VMEntry): + vm_hdd = int(bitmath.parse_string(vm_entry.specs["os-ssd"]).to_MB()) + + if WITHOUT_CEPH: + _command_to_create = [ + "cp", + os.path.join(IMAGE_DIR, vm_entry.image_uuid), + os.path.join(VM_DIR, vm_entry.uuid), + ] + + _command_to_extend = [ + "qemu-img", + "resize", + "-f", "raw", + os.path.join(VM_DIR, vm_entry.uuid), + "{}M".format(vm_hdd), + ] + else: + _command_to_create = [ + "rbd", + "clone", + "images/{}@protected".format(vm_entry.image_uuid), + "uservms/{}".format(vm_entry.uuid), + ] + + _command_to_extend = [ + "rbd", + "resize", + "uservms/{}".format(vm_entry.uuid), + "--size", + vm_hdd, + ] + + try: + subprocess.check_output(_command_to_create) + except subprocess.CalledProcessError as e: + if e.returncode == errno.EEXIST: + logging.debug("Image for vm %s exists", vm_entry.uuid) + # File Already exists. No Problem Continue + return + + # This exception catches all other exceptions + # i.e FileNotFound (BaseImage), pool Does Not Exists etc. + logging.exception(e) + + vm_entry.status = "ERROR" + else: + try: + subprocess.check_output(_command_to_extend) + except Exception as e: + logging.exception(e) + else: + logging.info("New VM Created") + + +def start(vm_entry: VMEntry): + _vm = get_vm(running_vms, vm_entry.key) + + # VM already running. No need to proceed further. + if _vm: + logging.info("VM %s already running", vm_entry.uuid) + return + else: + create(vm_entry) + launch_vm(vm_entry) + + +@need_running_vm +def stop(vm_entry): + vm = get_vm(running_vms, vm_entry.key) + vm.handle.shutdown() + if not vm.handle.is_running(): + vm_entry.add_log("Shutdown successfully") + vm_entry.declare_stopped() + vm_pool.put(vm_entry) + running_vms.remove(vm) + + +def delete(vm_entry): + logging.info("Deleting VM | %s", vm_entry) + stop(vm_entry) + path_without_protocol = vm_entry.path[vm_entry.path.find(":") + 1 :] + + if WITHOUT_CEPH: + vm_deletion_command = ["rm", os.path.join(VM_DIR, vm_entry.uuid)] + else: + vm_deletion_command = ["rbd", "rm", path_without_protocol] + + try: + subprocess.check_output(vm_deletion_command) + except Exception as e: + logging.exception(e) + else: + etcd_client.client.delete(vm_entry.key) + + +def transfer(request_event): + # This function would run on source host i.e host on which the vm + # is running initially. This host would be responsible for transferring + # vm state to destination host. + + _host, _port = request_event.parameters["host"], request_event.parameters["port"] + _uuid = request_event.uuid + _destination = request_event.destination_host_key + vm = get_vm(running_vms, join(VM_PREFIX, _uuid)) + + if vm: + tunnel = sshtunnel.SSHTunnelForwarder( + (_host, 22), + ssh_username=config("ssh_username"), + ssh_pkey=config("ssh_pkey"), + ssh_private_key_password=config("ssh_private_key_password"), + remote_bind_address=("127.0.0.1", _port), + ) + try: + tunnel.start() + except sshtunnel.BaseSSHTunnelForwarderError: + logging.exception("Couldn't establish connection to (%s, 22)", _host) + else: + vm.handle.command( + "migrate", uri="tcp:{}:{}".format(_host, tunnel.local_bind_port) + ) + + status = vm.handle.command("query-migrate")["status"] + while status not in ["failed", "completed"]: + time.sleep(2) + status = vm.handle.command("query-migrate")["status"] + + with vm_pool.get_put(request_event.uuid) as source_vm: + if status == "failed": + source_vm.add_log("Migration Failed") + elif status == "completed": + # If VM is successfully migrated then shutdown the VM + # on this host and update hostname to destination host key + source_vm.add_log("Successfully migrated") + source_vm.hostname = _destination + running_vms.remove(vm) + vm.handle.shutdown() + source_vm.in_migration = False # VM transfer finished + finally: + tunnel.close() + + +def init_migration(vm_entry, destination_host_key): + # This function would run on destination host i.e host on which the vm + # would be transferred after migration. + # This host would be responsible for starting VM that would receive + # state of VM running on source host. + + _vm = get_vm(running_vms, vm_entry.key) + + if _vm: + # VM already running. No need to proceed further. + logging.info("%s Already running", _vm.key) + return + + launch_vm(vm_entry, migration=True, migration_port=4444, + destination_host_key=destination_host_key) + + +def launch_vm(vm_entry, migration=False, migration_port=None, destination_host_key=None): + logging.info("Starting %s", vm_entry.key) + + vm = create_vm_object(vm_entry, migration=migration, migration_port=migration_port) + try: + vm.handle.launch() + except Exception as e: + logging.exception(e) + + if migration: + # We don't care whether MachineError or any other error occurred + vm.handle.shutdown() + else: + # Error during typical launch of a vm + vm_entry.add_log("Error Occurred while starting VM") + vm_entry.declare_killed() + vm_pool.put(vm_entry) + else: + vm_entry.vnc_socket = vm.vnc_socket_file.name + running_vms.append(vm) + + if migration: + vm_entry.in_migration = True + r = RequestEntry.from_scratch( + type=RequestType.TransferVM, + hostname=vm_entry.hostname, + parameters={"host": get_ipv4_address(), "port": 4444}, + uuid=vm_entry.uuid, + destination_host_key=destination_host_key, + ) + request_pool.put(r) + else: + # Typical launching of a vm + vm_entry.status = VMStatus.running + vm_entry.add_log("Started successfully") + + vm_pool.put(vm_entry) + \ No newline at end of file diff --git a/imagescanner/config.py b/imagescanner/config.py new file mode 100755 index 0000000..3ccc06b --- /dev/null +++ b/imagescanner/config.py @@ -0,0 +1,22 @@ +import logging + +from etcd3_wrapper import Etcd3Wrapper +from decouple import config + +BASE_PATH = config("BASE_DIR", "/var/www") +WITHOUT_CEPH = config("WITHOUT_CEPH", False, cast=bool) +ETCD_URL = config("ETCD_URL") +IMAGE_PREFIX = config("IMAGE_PREFIX") +IMAGE_STORE_PREFIX = config("IMAGE_STORE_PREFIX") +IMAGE_DIR = config("IMAGE_DIR") + +logging.basicConfig( + level=logging.DEBUG, + filename="log.txt", + filemode="a", + format="%(asctime)s: %(levelname)s - %(message)s", + datefmt="%d-%b-%y %H:%M:%S", +) + + +client = Etcd3Wrapper(host=ETCD_URL) diff --git a/imagescanner/main.py b/imagescanner/main.py new file mode 100755 index 0000000..f0956ac --- /dev/null +++ b/imagescanner/main.py @@ -0,0 +1,108 @@ +import os +import json +import subprocess +import sys + +from config import (logging, client, IMAGE_DIR, + BASE_PATH, WITHOUT_CEPH, + IMAGE_PREFIX, IMAGE_STORE_PREFIX) + + +def qemu_img_type(path): + qemu_img_info_command = ["qemu-img", "info", "--output", "json", path] + try: + qemu_img_info = subprocess.check_output(qemu_img_info_command) + except Exception as e: + logging.exception(e) + return None + else: + qemu_img_info = json.loads(qemu_img_info.decode("utf-8")) + return qemu_img_info["format"] + +# If you are using WITHOUT_CEPH FLAG in .env +# then please make sure that IMAGE_DIR directory +# exists otherwise this script would fail +if WITHOUT_CEPH and not os.path.isdir(IMAGE_DIR): + print("You have set WITHOUT_CEPH to True. So," + "the {} must exists. But, it don't".format(IMAGE_DIR)) + sys.exit(1) + +try: + subprocess.check_output(['which', 'qemu-img']) +except Exception: + print("qemu-img missing") + sys.exit(1) + +# We want to get images entries that requests images to be created +images = client.get_prefix(IMAGE_PREFIX, value_in_json=True) +images_to_be_created = list(filter(lambda im: im.value['status'] == 'TO_BE_CREATED', images)) + +for image in images_to_be_created: + try: + image_uuid = image.key.split('/')[-1] + image_owner = image.value['owner'] + image_filename = image.value['filename'] + image_store_name = image.value['store_name'] + image_full_path = os.path.join(BASE_PATH, image_owner, image_filename) + + image_stores = client.get_prefix(IMAGE_STORE_PREFIX, value_in_json=True) + user_image_store = next(filter( + lambda s, store_name=image_store_name: s.value["name"] == store_name, + image_stores + )) + + image_store_pool = user_image_store.value['attributes']['pool'] + + except Exception as e: + logging.exception(e) + else: + # At least our basic data is available + + qemu_img_convert_command = ["qemu-img", "convert", "-f", "qcow2", + "-O", "raw", image_full_path, "image.raw"] + + + if WITHOUT_CEPH: + image_import_command = ["mv", "image.raw", os.path.join(IMAGE_DIR, image_uuid)] + snapshot_creation_command = ["true"] + snapshot_protect_command = ["true"] + else: + image_import_command = ["rbd", "import", "image.raw", + "{}/{}".format(image_store_pool, image_uuid)] + snapshot_creation_command = ["rbd", "snap", "create", + "{}/{}@protected".format(image_store_pool, image_uuid)] + snapshot_protect_command = ["rbd", "snap", "protect", + "{}/{}@protected".format(image_store_pool, image_uuid)] + + + # First check whether the image is qcow2 + + if qemu_img_type(image_full_path) == "qcow2": + try: + # Convert .qcow2 to .raw + subprocess.check_output(qemu_img_convert_command) + + # Import image either to ceph/filesystem + subprocess.check_output(image_import_command) + + # Create and Protect Snapshot + subprocess.check_output(snapshot_creation_command) + subprocess.check_output(snapshot_protect_command) + + except Exception as e: + logging.exception(e) + + else: + # Everything is successfully done + image.value["status"] = "CREATED" + client.put(image.key, json.dumps(image.value)) + else: + # The user provided image is either not found or of invalid format + image.value["status"] = "INVALID_IMAGE" + client.put(image.key, json.dumps(image.value)) + + + try: + os.remove("image.raw") + except Exception: + pass diff --git a/metadata/config.py b/metadata/config.py new file mode 100644 index 0000000..0df4102 --- /dev/null +++ b/metadata/config.py @@ -0,0 +1,21 @@ +import logging + +from etcd3_wrapper import Etcd3Wrapper +from decouple import config + +from ucloud_common.vm import VmPool + +logging.basicConfig( + level=logging.DEBUG, + filename="log.txt", + filemode="a", + format="%(asctime)s: %(levelname)s - %(message)s", + datefmt="%d-%b-%y %H:%M:%S", +) + + +VM_PREFIX = config("VM_PREFIX") + +etcd_client = Etcd3Wrapper(host=config("ETCD_URL")) + +VM_POOL = VmPool(etcd_client, VM_PREFIX) diff --git a/metadata/main.py b/metadata/main.py new file mode 100644 index 0000000..d9a0bb7 --- /dev/null +++ b/metadata/main.py @@ -0,0 +1,84 @@ +from flask import Flask, request +from flask_restful import Resource, Api +from config import etcd_client, VM_POOL + +app = Flask(__name__) +api = Api(app) + + +def get_vm_entry(mac_addr): + return next(filter(lambda vm: vm.mac == mac_addr, VM_POOL.vms), None) + + +# https://stackoverflow.com/questions/37140846/how-to-convert-ipv6-link-local-address-to-mac-address-in-python +def ipv62mac(ipv6): + # remove subnet info if given + subnet_index = ipv6.find('/') + if subnet_index != -1: + ipv6 = ipv6[:subnet_index] + + ipv6_parts = ipv6.split(':') + mac_parts = list() + for ipv6_part in ipv6_parts[-4:]: + while len(ipv6_part) < 4: + ipv6_part = '0' + ipv6_part + mac_parts.append(ipv6_part[:2]) + mac_parts.append(ipv6_part[-2:]) + + # modify parts to match MAC value + mac_parts[0] = '%02x' % (int(mac_parts[0], 16) ^ 2) + del mac_parts[4] + del mac_parts[3] + + return ':'.join(mac_parts) + + +class Root(Resource): + @staticmethod + def get(): + data = get_vm_entry(ipv62mac(request.remote_addr)) + + if not data: + return {'message': 'Metadata for such VM does not exists.'}, 404 + else: + + # {user_prefix}/{realm}/{name}/key + etcd_key = os.path.join(USER_PREFIX, data.value['owner_realm'], + data.value['owner'], 'key') + etcd_entry = etcd_client.get_prefix(etcd_key, value_in_json=True) + user_personal_ssh_keys = [key.value for key in etcd_entry] + data.value['metadata']['ssh-keys'] += user_personal_ssh_keys + return data.value['metadata'], 200 + + @staticmethod + def post(): + return {'message': 'Previous Implementation is deprecated.'} + # data = etcd_client.get("/v1/metadata/{}".format(request.remote_addr), value_in_json=True) + # print(data) + # if data: + # for k in request.json: + # if k not in data.value: + # data.value[k] = request.json[k] + # if k.endswith("-list"): + # data.value[k] = [request.json[k]] + # else: + # if k.endswith("-list"): + # data.value[k].append(request.json[k]) + # else: + # data.value[k] = request.json[k] + # etcd_client.put("/v1/metadata/{}".format(request.remote_addr), + # data.value, value_in_json=True) + # else: + # data = {} + # for k in request.json: + # data[k] = request.json[k] + # if k.endswith("-list"): + # data[k] = [request.json[k]] + # etcd_client.put("/v1/metadata/{}".format(request.remote_addr), + # data, value_in_json=True) + + +api.add_resource(Root, '/') + +if __name__ == '__main__': + app.run(debug=True, host="::", port="80") diff --git a/scheduler/config.py b/scheduler/config.py new file mode 100755 index 0000000..81e8503 --- /dev/null +++ b/scheduler/config.py @@ -0,0 +1,25 @@ +import logging + +from decouple import config +from etcd3_wrapper import Etcd3Wrapper +from ucloud_common.vm import VmPool +from ucloud_common.host import HostPool +from ucloud_common.request import RequestPool + +logging.basicConfig( + level=logging.DEBUG, + filename="log.txt", + filemode="a", + format="%(asctime)s: %(levelname)s - %(message)s", + datefmt="%d-%b-%y %H:%M:%S", +) + +vm_prefix = config("VM_PREFIX") +host_prefix = config("HOST_PREFIX") +request_prefix = config("REQUEST_PREFIX") + +etcd_client = Etcd3Wrapper(host=config("ETCD_URL")) + +vm_pool = VmPool(etcd_client, vm_prefix) +host_pool = HostPool(etcd_client, host_prefix) +request_pool = RequestPool(etcd_client, request_prefix) \ No newline at end of file diff --git a/scheduler/helper.py b/scheduler/helper.py new file mode 100755 index 0000000..65bd12d --- /dev/null +++ b/scheduler/helper.py @@ -0,0 +1,123 @@ +import bitmath + +from collections import Counter +from functools import reduce + +from ucloud_common.vm import VmPool, VMStatus +from ucloud_common.host import HostPool, HostStatus +from ucloud_common.request import RequestEntry, RequestPool, RequestType + +from decouple import config +from config import etcd_client as client + +vm_pool = VmPool(client, config("VM_PREFIX")) +host_pool = HostPool(client, config("HOST_PREFIX")) +request_pool = RequestPool(client, config("REQUEST_PREFIX")) + + +def accumulated_specs(vms_specs): + if not vms_specs: + return {} + return reduce((lambda x, y: Counter(x) + Counter(y)), vms_specs) + + +def remaining_resources(host_specs, vms_specs): + # Return remaining resources host_specs - vms + + _vms_specs = Counter(vms_specs) + _remaining = Counter(host_specs) + + for component in _vms_specs: + if isinstance(_vms_specs[component], str): + _vms_specs[component] = int(bitmath.parse_string(_vms_specs[component]).to_MB()) + elif isinstance(_vms_specs[component], list): + _vms_specs[component] = map(lambda x: int(bitmath.parse_string(x).to_MB()), _vms_specs[component]) + _vms_specs[component] = reduce(lambda x, y: x + y, _vms_specs[component], 0) + + for component in _remaining: + if isinstance(_remaining[component], str): + _remaining[component] = int(bitmath.parse_string(_remaining[component]).to_MB()) + elif isinstance(_remaining[component], list): + _remaining[component] = map(lambda x: int(bitmath.parse_string(x).to_MB()), _remaining[component]) + _remaining[component] = reduce(lambda x, y: x + y, _remaining[component], 0) + + print(_vms_specs, _remaining) + _remaining.subtract(_vms_specs) + + return _remaining + + +class NoSuitableHostFound(Exception): + """Exception when no host found that can host a VM.""" + + +def get_suitable_host(vm_specs, hosts=None): + if hosts is None: + hosts = host_pool.by_status(HostStatus.alive) + + for host in hosts: + # Filter them by host_name + vms = vm_pool.by_host(host.key) + + # Filter them by status + vms = vm_pool.by_status(VMStatus.running, vms) + + running_vms_specs = [vm.specs for vm in vms] + + # Accumulate all of their combined specs + running_vms_accumulated_specs = accumulated_specs( + running_vms_specs + ) + + # Find out remaining resources after + # host_specs - already running vm_specs + remaining = remaining_resources( + host.specs, running_vms_accumulated_specs + ) + + # Find out remaining - new_vm_specs + remaining = remaining_resources(remaining, vm_specs) + + if all(map(lambda x: x >= 0, remaining.values())): + return host.key + + raise NoSuitableHostFound + + +def dead_host_detection(): + # Bring out your dead! - Monty Python and the Holy Grail + hosts = host_pool.by_status(HostStatus.alive) + dead_hosts_keys = [] + + for host in hosts: + # Only check those who claims to be alive + if host.status == HostStatus.alive: + if not host.is_alive(): + dead_hosts_keys.append(host.key) + + return dead_hosts_keys + + +def dead_host_mitigation(dead_hosts_keys): + for host_key in dead_hosts_keys: + host = host_pool.get(host_key) + host.declare_dead() + + vms_hosted_on_dead_host = vm_pool.by_host(host_key) + for vm in vms_hosted_on_dead_host: + vm.declare_killed() + vm_pool.put(vm) + host_pool.put(host) + + +def assign_host(vm): + vm.hostname = get_suitable_host(vm.specs) + vm_pool.put(vm) + + r = RequestEntry.from_scratch(type=RequestType.StartVM, + uuid=vm.uuid, + hostname=vm.hostname) + request_pool.put(r) + + vm.log.append("VM scheduled for starting") + return vm.hostname diff --git a/scheduler/main.py b/scheduler/main.py new file mode 100755 index 0000000..4ce178f --- /dev/null +++ b/scheduler/main.py @@ -0,0 +1,89 @@ +# TODO +# 1. send an email to an email address defined by env['admin-email'] +# if resources are finished +# 2. Introduce a status endpoint of the scheduler - +# maybe expose a prometheus compatible output + +import logging + +from ucloud_common.request import RequestEntry, RequestType + +from config import etcd_client as client +from config import (host_pool, request_pool, vm_pool, request_prefix) +from helper import (get_suitable_host, dead_host_mitigation, dead_host_detection, + assign_host, NoSuitableHostFound) + + + +def main(): + pending_vms = [] + + for request_iterator in [ + client.get_prefix(request_prefix, value_in_json=True), + client.watch_prefix(request_prefix, timeout=5, value_in_json=True), + ]: + for request_event in request_iterator: + request_entry = RequestEntry(request_event) + logging.debug("%s, %s", request_entry.key, request_entry.value) + + # Never Run time critical mechanism inside timeout + # mechanism because timeout mechanism only comes + # when no other event is happening. It means under + # heavy load there would not be a timeout event. + if request_entry.type == "TIMEOUT": + + # Detect hosts that are dead and set their status + # to "DEAD", and their VMs' status to "KILLED" + logging.debug("TIMEOUT event occured") + dead_hosts = dead_host_detection() + logging.debug("Dead hosts: %s", dead_hosts) + dead_host_mitigation(dead_hosts) + + # If there are VMs that weren't assigned a host + # because there wasn't a host available which + # meets requirement of that VM then we would + # create a new ScheduleVM request for that VM + # on our behalf. + while pending_vms: + pending_vm_entry = pending_vms.pop() + r = RequestEntry.from_scratch(type="ScheduleVM", + uuid=pending_vm_entry.uuid, + hostname=pending_vm_entry.hostname) + request_pool.put(r) + + elif request_entry.type == RequestType.ScheduleVM: + vm_entry = vm_pool.get(request_entry.uuid) + client.client.delete(request_entry.key) # consume Request + + # If the Request is about a VM which is labelled as "migration" + # and has a destination + if hasattr(request_entry, "migration") and request_entry.migration \ + and hasattr(request_entry, "destination") and request_entry.destination: + try: + get_suitable_host(vm_specs=vm_entry.specs, + hosts=[host_pool.get(request_entry.destination)]) + except NoSuitableHostFound: + logging.info("Requested destination host doesn't have enough capacity" + "to hold %s", vm_entry.uuid) + else: + r = RequestEntry.from_scratch(type=RequestType.InitVMMigration, + uuid=request_entry.uuid, + destination=request_entry.destination) + request_pool.put(r) + + # If the Request is about a VM that just want to get started/created + else: + # assign_host only returns None when we couldn't be able to assign + # a host to a VM because of resource constraints + try: + assign_host(vm_entry) + except NoSuitableHostFound: + vm_entry.log.append("Can't schedule VM. No Resource Left.") + vm_pool.put(vm_entry) + + pending_vms.append(vm_entry) + logging.info("No Resource Left. Emailing admin....") + + +logging.info("%s SESSION STARTED %s", '*' * 5, '*' * 5) +main() diff --git a/scheduler/tests/test_basics.py b/scheduler/tests/test_basics.py new file mode 100755 index 0000000..227e84b --- /dev/null +++ b/scheduler/tests/test_basics.py @@ -0,0 +1,214 @@ +import unittest +import sys +import json +import multiprocessing +import time + +from datetime import datetime +from os.path import dirname + + +BASE_DIR = dirname(dirname(__file__)) +sys.path.insert(0, BASE_DIR) + +from main import ( + accumulated_specs, + remaining_resources, + VmPool, + dead_host_detection, + dead_host_mitigation, + main, +) + +from config import etcd_client + +class TestFunctions(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.client = etcd_client + cls.host_prefix = "/test/host" + cls.vm_prefix = "/test/vm" + + # These deletion could also be in + # tearDown() but it is more appropriate here + # as it enable us to check the ETCD store + # even after test is run + cls.client.client.delete_prefix(cls.host_prefix) + cls.client.client.delete_prefix(cls.vm_prefix) + cls.create_hosts(cls) + cls.create_vms(cls) + + cls.p = multiprocessing.Process( + target=main, args=[cls.vm_prefix, cls.host_prefix] + ) + cls.p.start() + + @classmethod + def tearDownClass(cls): + cls.p.terminate() + + def create_hosts(self): + host1 = { + "cpu": 32, + "ram": 128, + "hdd": 1024, + "sdd": 0, + "status": "ALIVE", + "last_heartbeat": datetime.utcnow().isoformat(), + } + host2 = { + "cpu": 16, + "ram": 64, + "hdd": 512, + "sdd": 0, + "status": "ALIVE", + "last_heartbeat": datetime.utcnow().isoformat(), + } + + host3 = { + "cpu": 16, + "ram": 32, + "hdd": 256, + "sdd": 256, + "status": "ALIVE", + "last_heartbeat": datetime.utcnow().isoformat(), + } + with self.client.client.lock("lock"): + self.client.put(f"{self.host_prefix}/1", host1, value_in_json=True) + self.client.put(f"{self.host_prefix}/2", host2, value_in_json=True) + self.client.put(f"{self.host_prefix}/3", host3, value_in_json=True) + + def create_vms(self): + vm1 = json.dumps( + { + "owner": "meow", + "specs": {"cpu": 4, "ram": 8, "hdd": 100, "sdd": 256}, + "hostname": "", + "status": "REQUESTED_NEW", + } + ) + vm2 = json.dumps( + { + "owner": "meow", + "specs": {"cpu": 16, "ram": 64, "hdd": 512, "sdd": 0}, + "hostname": "", + "status": "REQUESTED_NEW", + } + ) + vm3 = json.dumps( + { + "owner": "meow", + "specs": {"cpu": 16, "ram": 32, "hdd": 128, "sdd": 0}, + "hostname": "", + "status": "REQUESTED_NEW", + } + ) + vm4 = json.dumps( + { + "owner": "meow", + "specs": {"cpu": 16, "ram": 64, "hdd": 512, "sdd": 0}, + "hostname": "", + "status": "REQUESTED_NEW", + } + ) + vm5 = json.dumps( + { + "owner": "meow", + "specs": {"cpu": 2, "ram": 2, "hdd": 10, "sdd": 0}, + "hostname": "", + "status": "REQUESTED_NEW", + } + ) + vm6 = json.dumps( + { + "owner": "meow", + "specs": {"cpu": 10, "ram": 22, "hdd": 146, "sdd": 0}, + "hostname": "", + "status": "REQUESTED_NEW", + } + ) + vm7 = json.dumps( + { + "owner": "meow", + "specs": {"cpu": 10, "ram": 22, "hdd": 146, "sdd": 0}, + "hostname": "", + "status": "REQUESTED_NEW", + } + ) + self.client.put(f"{self.vm_prefix}/1", vm1) + self.client.put(f"{self.vm_prefix}/2", vm2) + self.client.put(f"{self.vm_prefix}/3", vm3) + self.client.put(f"{self.vm_prefix}/4", vm4) + self.client.put(f"{self.vm_prefix}/5", vm5) + self.client.put(f"{self.vm_prefix}/6", vm6) + self.client.put(f"{self.vm_prefix}/7", vm7) + + def test_accumulated_specs(self): + vms = [ + {"ssd": 10, "cpu": 4, "ram": 8}, + {"hdd": 10, "cpu": 4, "ram": 8}, + {"cpu": 8, "ram": 32}, + ] + self.assertEqual( + accumulated_specs(vms), {"ssd": 10, "cpu": 16, "ram": 48, "hdd": 10} + ) + + def test_remaining_resources(self): + host_specs = {"ssd": 10, "cpu": 16, "ram": 48, "hdd": 10} + vms_specs = {"ssd": 10, "cpu": 32, "ram": 12, "hdd": 0} + resultant_specs = {"ssd": 0, "cpu": -16, "ram": 36, "hdd": 10} + self.assertEqual(remaining_resources(host_specs, vms_specs), + resultant_specs) + + def test_vmpool(self): + self.p.join(1) + vm_pool = VmPool(self.client, self.vm_prefix) + + # vm_pool by host + actual = vm_pool.by_host(vm_pool.vms, f"{self.host_prefix}/3") + ground_truth = [ + ( + f"{self.vm_prefix}/1", + { + "owner": "meow", + "specs": {"cpu": 4, "ram": 8, "hdd": 100, "sdd": 256}, + "hostname": f"{self.host_prefix}/3", + "status": "SCHEDULED_DEPLOY", + }, + ) + ] + self.assertEqual(actual[0], ground_truth[0]) + + # vm_pool by status + actual = vm_pool.by_status(vm_pool.vms, "REQUESTED_NEW") + ground_truth = [ + ( + f"{self.vm_prefix}/7", + { + "owner": "meow", + "specs": {"cpu": 10, "ram": 22, "hdd": 146, "sdd": 0}, + "hostname": "", + "status": "REQUESTED_NEW", + }, + ) + ] + self.assertEqual(actual[0], ground_truth[0]) + + # vm_pool by except status + actual = vm_pool.except_status(vm_pool.vms, "SCHEDULED_DEPLOY") + ground_truth = [ + ( + f"{self.vm_prefix}/7", + { + "owner": "meow", + "specs": {"cpu": 10, "ram": 22, "hdd": 146, "sdd": 0}, + "hostname": "", + "status": "REQUESTED_NEW", + }, + ) + ] + self.assertEqual(actual[0], ground_truth[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scheduler/tests/test_dead_host_mechanism.py b/scheduler/tests/test_dead_host_mechanism.py new file mode 100755 index 0000000..33bed23 --- /dev/null +++ b/scheduler/tests/test_dead_host_mechanism.py @@ -0,0 +1,81 @@ +import unittest +import sys +import json +import multiprocessing +import time + +from datetime import datetime +from os.path import dirname +BASE_DIR = dirname(dirname(__file__)) +sys.path.insert(0, BASE_DIR) + +from main import ( + accumulated_specs, + remaining_resources, + VmPool, + dead_host_detection, + dead_host_mitigation, + main, + config +) + +class TestDeadHostMechanism(unittest.TestCase): + def setUp(self): + self.client = config.etcd_client + self.host_prefix = "/test/host" + self.vm_prefix = "/test/vm" + + self.client.client.delete_prefix(self.host_prefix) + self.client.client.delete_prefix(self.vm_prefix) + + self.create_hosts() + + def create_hosts(self): + host1 = { + "cpu": 32, + "ram": 128, + "hdd": 1024, + "sdd": 0, + "status": "ALIVE", + "last_heartbeat": datetime.utcnow().isoformat(), + } + host2 = { + "cpu": 16, + "ram": 64, + "hdd": 512, + "sdd": 0, + "status": "ALIVE", + "last_heartbeat": datetime(2011, 1, 1).isoformat(), + } + + host3 = {"cpu": 16, "ram": 32, "hdd": 256, "sdd": 256} + host4 = { + "cpu": 16, + "ram": 32, + "hdd": 256, + "sdd": 256, + "status": "DEAD", + "last_heartbeat": datetime(2011, 1, 1).isoformat(), + } + with self.client.client.lock("lock"): + self.client.put(f"{self.host_prefix}/1", host1, value_in_json=True) + self.client.put(f"{self.host_prefix}/2", host2, value_in_json=True) + self.client.put(f"{self.host_prefix}/3", host3, value_in_json=True) + self.client.put(f"{self.host_prefix}/4", host4, value_in_json=True) + + def test_dead_host_detection(self): + hosts = self.client.get_prefix(self.host_prefix, value_in_json=True) + deads = dead_host_detection(hosts) + self.assertEqual(deads, ["/test/host/2", "/test/host/3"]) + return deads + + def test_dead_host_mitigation(self): + deads = self.test_dead_host_detection() + dead_host_mitigation(self.client, deads) + hosts = self.client.get_prefix(self.host_prefix, value_in_json=True) + deads = dead_host_detection(hosts) + self.assertEqual(deads, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/ucloud.py b/ucloud.py new file mode 100644 index 0000000..5886502 --- /dev/null +++ b/ucloud.py @@ -0,0 +1,16 @@ +import argparse +import subprocess as sp +arg_parser = argparse.ArgumentParser(prog='ucloud', + description='Open Source Cloud Management Software') +arg_parser.add_argument('component', + choices=['api', 'scheduler', 'host', + 'filescanner','imagescanner', + 'metadata']) +arg_parser.add_argument('component_args', nargs='*') +args = arg_parser.parse_args() + +try: + command = ['pipenv', 'run', 'python', 'main.py', *args.component_args] + sp.run(command, cwd=args.component) +except Exception as error: + print(error) From a2547bcd8361254403d50ed7b38a30050938dfaf Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 1 Nov 2019 17:51:06 +0100 Subject: [PATCH 002/284] begin networking --- network/README | 60 ++++++++++++++++++++++++++++++++++ network/create-vxlan-on-dev.sh | 17 ++++++++++ 2 files changed, 77 insertions(+) create mode 100644 network/README create mode 100644 network/create-vxlan-on-dev.sh diff --git a/network/README b/network/README new file mode 100644 index 0000000..e32acd9 --- /dev/null +++ b/network/README @@ -0,0 +1,60 @@ +The network base - experimental + + +We want to have 1 "main" network for convience. + +We want to be able to create networks automatically, once a new +customer is created -> need hooks! + + +Mapping: + +- each network is a "virtual" network. We use vxlan by default, but + could be any technology! +- we need a counter for vxlan mappings / network IDs -> cannot use + +Model in etcd: + +/v1/networks/ + + +Tests +see +https://vincent.bernat.ch/en/blog/2017-vxlan-linux + + +# local 2001:db8:1::1 \ + + +netid=100 +dev=wlp2s0 +dev=wlp0s20f3 +ip -6 link add vxlan${netid} type vxlan \ + id ${netid} \ + dstport 4789 \ + group ff05::${netid} \ + dev ${dev} \ + ttl 5 + +[root@diamond ~]# ip addr add 2a0a:e5c0:5::1/48 dev vxlan100 +root@manager:~/.ssh# ip addr add 2a0a:e5c0:5::2/48 dev vxlan100 +root@manager:~/.ssh# ping -c3 2a0a:e5c0:5::1 +PING 2a0a:e5c0:5::1(2a0a:e5c0:5::1) 56 data bytes +64 bytes from 2a0a:e5c0:5::1: icmp_seq=1 ttl=64 time=15.6 ms +64 bytes from 2a0a:e5c0:5::1: icmp_seq=2 ttl=64 time=30.3 ms +64 bytes from 2a0a:e5c0:5::1: icmp_seq=3 ttl=64 time=84.4 ms + +--- 2a0a:e5c0:5::1 ping statistics --- +3 packets transmitted, 3 received, 0% packet loss, time 2003ms +rtt min/avg/max/mdev = 15.580/43.437/84.417/29.594 ms + +--> work even via wifi + + +-------------------------------------------------------------------------------- + +Creating a network: + +1) part of the initialisation / demo data (?) + +We should probably provide some demo sets that can easily be used. diff --git a/network/create-vxlan-on-dev.sh b/network/create-vxlan-on-dev.sh new file mode 100644 index 0000000..87687c9 --- /dev/null +++ b/network/create-vxlan-on-dev.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +if [ $# -ne 2 ]; then + echo "$0 vxlanid dev" + echo "f.i. $0 100 eth0" + exit 1 +fi + +netid=$1; shift +dev=$1; shift + +ip -6 link add vxlan${netid} type vxlan \ + id ${netid} \ + dstport 4789 \ + group ff05::${netid} \ + dev ${dev} \ + ttl 5 From b27f1b62f380a8fbc086111bfbfb406607097c11 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 1 Nov 2019 17:54:02 +0100 Subject: [PATCH 003/284] network: up the dev some kernels do that automatically, some don't --- network/create-vxlan-on-dev.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/network/create-vxlan-on-dev.sh b/network/create-vxlan-on-dev.sh index 87687c9..b366392 100644 --- a/network/create-vxlan-on-dev.sh +++ b/network/create-vxlan-on-dev.sh @@ -15,3 +15,5 @@ ip -6 link add vxlan${netid} type vxlan \ group ff05::${netid} \ dev ${dev} \ ttl 5 + +ip link set ${dev} up From 1a76150d4d0dedaf00b039125b6c1e8e3725c834 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 1 Nov 2019 21:51:28 +0100 Subject: [PATCH 004/284] ++ network readme update --- network/README | 112 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/network/README b/network/README index e32acd9..29863ab 100644 --- a/network/README +++ b/network/README @@ -58,3 +58,115 @@ Creating a network: 1) part of the initialisation / demo data (?) We should probably provide some demo sets that can easily be used. + +2) manual/hook based request + +- hosts might have different network interfaces (?) + -> this will make things very tricky -> don't support it +- endpoint needs only support + +-------------------------------------------------------------------------------- + +IPAM + +IP address management (IPAM) is related to networks, but needs to be +decoupled to allow pure L2 networks. + +From a customer point of view, we probably want to do something like: + +- ORDERING an IPv6 network can include creating a virtual network and + an IPAM service + +Maybe "orders" should always be the first class citizen and ucloud +internally "hooks" or binds things together. + +-------------------------------------------------------------------------------- + +testing / hacking: + +- starting etcd as storage + + +[18:07] diamond:~% etcdctl put /v1/network/200 "{ some_network }" +OK +[18:08] diamond:~% etcdctl watch -w=json --prefix /v1/network +{"Header":{"cluster_id":14841639068965178418,"member_id":10276657743932975437,"revision":6,"raft_term":2},"Events":[{"kv":{"key":"L3YxL25ldHdvcmsvMjAw","create_revision":5,"mod_revision":6,"version":2,"value":"eyBzb21lX25ldHdvcmsgfQ=="}}],"CompactRevision":0,"Canceled":false,"Created":false} + + +-------------------------------------------------------------------------------- + +Flow for using and creating networks: + +- a network is created -> entry in etcd is created + -> we need to keep a counter/lock so that 2 processes don't create + the same network [Ahmed] + -> nothing to be done on the hosts +- a VM using a network is created +- a VM using a network is scheduled to some host +- the local "spawn a VM" process needs to check whether there is a + vxlan interface existing -> if no, create it before creating the VM. + -> if no, also create the bridge + -> possibly adjusting the MTU (??) + -> both names should be in hexadecimal (i.e. brff01 or vxlanff01) + --> this way they are consistent with the multicast ipv6 address + --> attention, ip -6 link ... id XXX expects DECIMAL input + +-------------------------------------------------------------------------------- + + + + +Example + +-------------------------------------------------------------------------------- + +TODOs + +- create-vxlan-on-dev.sh -> the multicast group + needs to be ff05:: +int(vxlan_id) + +-------------------------------------------------------------------------------- + +Python hints: + +>>> vxlan_id = 3400 +>>> b = ipaddress.IPv6Network("ff05::/16") +>>> b[vxlan_id] +IPv6Address('ff05::d48') + +we need / should assign hex values for vxlan ids in etcd! +--> easier to read + +>>> b[0x3400] +IPv6Address('ff05::3400') + + +-------------------------------------------------------------------------------- + +Bridge names are limited to 15 characters + + +Maximum/highest number of vxlan: + +>>> 2**24 +16777216 +>>> (2**25)-1 +33554431 + +>>> b[33554431] +IPv6Address('ff05::1ff:ffff') + +Last interface: +br1ffffff +vxlan1ffffff + +root@manager:~/ucloud/network# ip -6 link add vxlan1ffffff type vxlan id 33554431 dstport 4789 group ff05::1ff:ffff dev wlp2s0 ttl 5 +Error: argument "33554431" is wrong: invalid id + +root@manager:~/ucloud/network# ip -6 link add vxlanffffff type vxlan id 16777215 dstport 4789 group ff05::ff:ffff dev wlp2s0 ttl 5 + + +# id needs to be decimal +root@manager:~# ip -6 link add vxlanff01 type vxlan id ff01 dstport 4789 group ff05::ff01 dev ttl 5 +Error: argument "ff01" is wrong: invalid id +root@manager:~# ip -6 link add vxlanff01 type vxlan id 65281 dstport 4789 group ff05::ff01 dev wlp2s0 ttl 5 From 583bbe34bc17df51f62362b720318fd2da058d44 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 1 Nov 2019 23:13:40 +0100 Subject: [PATCH 005/284] ++ network ideas --- network/README | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/network/README b/network/README index 29863ab..dca25d1 100644 --- a/network/README +++ b/network/README @@ -112,9 +112,32 @@ Flow for using and creating networks: --> attention, ip -6 link ... id XXX expects DECIMAL input -------------------------------------------------------------------------------- +If we also supply IPAM: + +- ipam needs to be created *after* the network is created +- ipam is likely to be coupled to netbox (?) + --> we need a "get next /64 prefix" function +- when an ipam service is created in etcd, we need to create a new + radvd instance on all routers (this will be a different service on + BSDs) +- we will need to create a new vxlan device on the routers +- we need to create a new / modify radvd.conf +- only after all of the routers reloaded radvd the ipam service is + available! + + +-------------------------------------------------------------------------------- +If the user requests an IPv4 VM: + +- we need to get the next free IPv4 address (again, netbox?) +- we need to create a mapping entry on the routers for NAT64 + --> this requires the VM to be in a network with IPAM + --> we always assume that the VM embeds itself using EUI64 +-------------------------------------------------------------------------------- +mac address handling! Example From 93dee1c9fcd18e095d355f026409afa6e43f5b91 Mon Sep 17 00:00:00 2001 From: meow Date: Sat, 2 Nov 2019 20:42:24 +0500 Subject: [PATCH 006/284] New Features + Refactoring 1. User can now use image name instead of image uuid when creation vm. For Example, now user can create an alpine vm using the following command ```shell ucloud-cli vm create --vm-name myvm --cpu 2 --ram '2GB' \ --os-ssd '10GB' --image images:alpine ``` 2. Instead of directly running code, code is now placed under a function main and is called using the following code ```python if __name__ == "__main__": main() ``` 3. Multiprocess (Process) is used instead of threading (Thread) to update heart beat of host. 4. IP Address of vm is included in vm's status which is retrieved by the following command ```shell ucloud-cli vm status --vm-name myvm ``` --- .gitignore | 2 + Pipfile.lock | 60 ++++++++------- TODO.md | 3 +- api/helper.py | 76 ++++++++++++++++++- api/main.py | 56 +++++++------- api/schemas.py | 20 +++-- filescanner/main.py | 125 ++++++++++++++++++------------- host/config.py | 5 +- host/main.py | 55 ++++++++------ imagescanner/main.py | 172 ++++++++++++++++++++++--------------------- metadata/config.py | 1 + metadata/main.py | 5 +- scheduler/main.py | 7 +- 13 files changed, 354 insertions(+), 233 deletions(-) diff --git a/.gitignore b/.gitignore index 5430a7a..bfdbce1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ .vscode .env +__pycache__ + */log.txt \ No newline at end of file diff --git a/Pipfile.lock b/Pipfile.lock index aaa7369..62c17ca 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -83,10 +83,12 @@ "sha256:7cfcfda59ef1f95b9f729c56fe8a4041899f96b72685d36ef16a3440a0f85da8", "sha256:819f8d5197c2684524637f940445c06e003c4a541f9983fd30d6deaa2a5487d8", "sha256:825ecffd9574557590e3225560a8a9d751f6ffe4a49e3c40918c9969b93395fa", + "sha256:8a2bcae2258d00fcfc96a9bde4a6177bc4274fe033f79311c5dd3d3148c26518", "sha256:9009e917d8f5ef780c2626e29b6bc126f4cb2a4d43ca67aa2b40f2a5d6385e78", "sha256:9c77564a51d4d914ed5af096cd9843d90c45b784b511723bd46a8a9d09cf16fc", "sha256:a19089fa74ed19c4fe96502a291cfdb89223a9705b1d73b3005df4256976142e", "sha256:a40ed527bffa2b7ebe07acc5a3f782da072e262ca994b4f2085100b5a444bbb2", + "sha256:b8f09f21544b9899defb09afbdaeb200e6a87a2b8e604892940044cf94444644", "sha256:bb75ba21d5716abc41af16eac1145ab2e471deedde1f22c6f99bd9f995504df0", "sha256:e22a00c0c81ffcecaf07c2bfb3672fa372c50e2bd1024ffee0da191c1b27fc71", "sha256:e55b5a746fb77f10c83e8af081979351722f6ea48facea79d470b3731c7b2891", @@ -293,7 +295,6 @@ }, "pycparser": { "hashes": [ - "sha256:9d97450dc26e1d2581c18881d8d1c0a92e84c9ac074961e3dc66e70d745a0643", "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" ], "version": "==2.19" @@ -375,15 +376,15 @@ }, "tenacity": { "hashes": [ - "sha256:6a7511a59145c2e319b7d04ddd93c12d48cc3d3c8fa42c2846d33a620ee91f57", - "sha256:a4eb168dbf55ed2cae27e7c6b2bd48ab54dabaf294177d998330cf59f294c112" + "sha256:3a916e734559f1baa2cab965ee00061540c41db71c3bf25375b81540a19758fc", + "sha256:e664bd94f088b17f46da33255ae33911ca6a0fe04b156d334b601a4ef66d3c5f" ], - "version": "==5.1.1" + "version": "==5.1.5" }, "ucloud-common": { "editable": true, "git": "https://code.ungleich.ch/ucloud/ucloud_common.git", - "ref": "9f229eae27f9007e9c6c1021d3d5b12452863763" + "ref": "eba92e5d6723093a3cc2999ae1f5c284e65dc809" }, "urllib3": { "hashes": [ @@ -509,26 +510,29 @@ }, "lazy-object-proxy": { "hashes": [ - "sha256:02b260c8deb80db09325b99edf62ae344ce9bc64d68b7a634410b8e9a568edbf", - "sha256:18f9c401083a4ba6e162355873f906315332ea7035803d0fd8166051e3d402e3", - "sha256:1f2c6209a8917c525c1e2b55a716135ca4658a3042b5122d4e3413a4030c26ce", - "sha256:2f06d97f0ca0f414f6b707c974aaf8829c2292c1c497642f63824119d770226f", - "sha256:616c94f8176808f4018b39f9638080ed86f96b55370b5a9463b2ee5c926f6c5f", - "sha256:63b91e30ef47ef68a30f0c3c278fbfe9822319c15f34b7538a829515b84ca2a0", - "sha256:77b454f03860b844f758c5d5c6e5f18d27de899a3db367f4af06bec2e6013a8e", - "sha256:83fe27ba321e4cfac466178606147d3c0aa18e8087507caec78ed5a966a64905", - "sha256:84742532d39f72df959d237912344d8a1764c2d03fe58beba96a87bfa11a76d8", - "sha256:874ebf3caaf55a020aeb08acead813baf5a305927a71ce88c9377970fe7ad3c2", - "sha256:9f5caf2c7436d44f3cec97c2fa7791f8a675170badbfa86e1992ca1b84c37009", - "sha256:a0c8758d01fcdfe7ae8e4b4017b13552efa7f1197dd7358dc9da0576f9d0328a", - "sha256:a4def978d9d28cda2d960c279318d46b327632686d82b4917516c36d4c274512", - "sha256:ad4f4be843dace866af5fc142509e9b9817ca0c59342fdb176ab6ad552c927f5", - "sha256:ae33dd198f772f714420c5ab698ff05ff900150486c648d29951e9c70694338e", - "sha256:b4a2b782b8a8c5522ad35c93e04d60e2ba7f7dcb9271ec8e8c3e08239be6c7b4", - "sha256:c462eb33f6abca3b34cdedbe84d761f31a60b814e173b98ede3c81bb48967c4f", - "sha256:fd135b8d35dfdcdb984828c84d695937e58cc5f49e1c854eb311c4d6aa03f4f1" + "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d", + "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449", + "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08", + "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a", + "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50", + "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd", + "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239", + "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb", + "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea", + "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e", + "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156", + "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142", + "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442", + "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62", + "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db", + "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531", + "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383", + "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a", + "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357", + "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", + "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0" ], - "version": "==1.4.2" + "version": "==1.4.3" }, "mccabe": { "hashes": [ @@ -743,11 +747,11 @@ }, "typing-extensions": { "hashes": [ - "sha256:2ed632b30bb54fc3941c382decfd0ee4148f5c591651c9272473fea2c6397d95", - "sha256:b1edbbf0652660e32ae780ac9433f4231e7339c7f9a8057d0f042fcbcea49b87", - "sha256:d8179012ec2c620d3791ca6fe2bf7979d979acdbef1fca0bc56b37411db682ed" + "sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2", + "sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d", + "sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575" ], - "version": "==3.7.4" + "version": "==3.7.4.1" }, "urllib3": { "hashes": [ diff --git a/TODO.md b/TODO.md index 09f6205..20be658 100644 --- a/TODO.md +++ b/TODO.md @@ -2,5 +2,4 @@ - Check for `etcd3.exceptions.ConnectionFailedError` when calling some etcd operation to avoid crashing whole application -- Throw KeyError instead of returning None when some key is not found in etcd -- Specify image format when using qemu-img when creating virtual machine \ No newline at end of file +- Throw KeyError instead of returning None when some key is not found in etcd \ No newline at end of file diff --git a/api/helper.py b/api/helper.py index 67a0379..06b45b1 100755 --- a/api/helper.py +++ b/api/helper.py @@ -1,9 +1,12 @@ import binascii import requests +import random +import subprocess as sp +import ipaddress from decouple import config from pyotp import TOTP -from config import VM_POOL +from config import VM_POOL, etcd_client, IMAGE_PREFIX def check_otp(name, realm, token): @@ -47,7 +50,47 @@ def resolve_vm_name(name, owner): return None -import random + +def resolve_image_name(name, etcd_client): + """Return image uuid given its name and its store + + * If the provided name is not in correct format + i.e {store_name}:{image_name} return ValueError + * If no such image found then return KeyError + + """ + + seperator = ":" + + # Ensure, user/program passed valid name that is of type string + try: + store_name_and_image_name = name.split(seperator) + + """ + Examples, where it would work and where it would raise exception + "images:alpine" --> ["images", "alpine"] + + "images" --> ["images"] it would raise Exception as non enough value to unpack + + "images:alpine:meow" --> ["images", "alpine", "meow"] it would raise Exception + as too many values to unpack + """ + store_name, image_name = store_name_and_image_name + except Exception: + raise ValueError("Image name not in correct format i.e {store_name}:{image_name}") + + images = etcd_client.get_prefix(IMAGE_PREFIX, value_in_json=True) + + # Try to find image with name == image_name and store_name == store_name + try: + image = next(filter(lambda im: im.value['name'] == image_name \ + and im.value['store_name'] == store_name, images)) + except StopIteration: + raise KeyError("No image with name {} found.".format(name)) + else: + image_uuid = image.key.split('/')[-1] + + return image_uuid def random_bytes(num=6): return [random.randrange(256) for _ in range(num)] @@ -68,3 +111,32 @@ def generate_mac(uaa=False, multicast=False, oui=None, separator=':', byte_fmt=' else: mac[0] |= 1 << 1 # set bit 1 return separator.join(byte_fmt % b for b in mac) + +def get_ip_addr(mac_address, device): + """Return IP address of a device provided its mac address / link local address + and the device with which it is connected. + + For Example, if we call get_ip_addr(mac_address="52:54:00:12:34:56", device="br0") + the following two scenarios can happen + 1. It would return None if we can't be able to find device whose mac_address is equal + to the arg:mac_address or the mentioned arg:device does not exists or the ip address + we found is local. + 2. It would return ip_address of device whose mac_address is equal to arg:mac_address + and is connected/neighbor of arg:device + """ + try: + output = sp.check_output(['ip','-6','neigh', 'show', 'dev', device], stderr=sp.PIPE) + except sp.CalledProcessError: + return None + else: + result = [] + output = output.strip().decode("utf-8") + output = output.split("\n") + for entry in output: + entry = entry.split() + if entry: + ip = ipaddress.ip_address(entry[0]) + mac = entry[2] + if ip.is_global and mac_address == mac: + result.append(ip) + return result diff --git a/api/main.py b/api/main.py index e8082d5..1b25802 100644 --- a/api/main.py +++ b/api/main.py @@ -1,6 +1,9 @@ import json import subprocess import os + +import schemas + from uuid import uuid4 from flask import Flask, request @@ -9,7 +12,7 @@ from flask_restful import Resource, Api from ucloud_common.vm import VMStatus from ucloud_common.request import RequestEntry, RequestType -from helper import generate_mac +from helper import generate_mac, get_ip_addr from config import ( etcd_client, @@ -23,18 +26,6 @@ from config import ( VM_POOL, HOST_POOL, ) -from schemas import ( - CreateVMSchema, - VMStatusSchema, - CreateImageSchema, - VmActionSchema, - OTPSchema, - CreateHostSchema, - VmMigrationSchema, - AddSSHSchema, - RemoveSSHSchema, - GetSSHSchema -) app = Flask(__name__) api = Api(app) @@ -45,7 +36,7 @@ class CreateVM(Resource): def post(): data = request.json print(data) - validator = CreateVMSchema(data) + validator = schemas.CreateVMSchema(data) if validator.is_valid(): vm_uuid = uuid4().hex vm_key = os.path.join(VM_PREFIX, vm_uuid) @@ -63,7 +54,7 @@ class CreateVM(Resource): "specs": specs, "hostname": "", "status": "", - "image_uuid": data["image_uuid"], + "image_uuid": validator.image_uuid, "log": [], "vnc_socket": "", "mac": str(generate_mac()), @@ -85,10 +76,14 @@ class VmStatus(Resource): @staticmethod def get(): data = request.json - validator = VMStatusSchema(data) + validator = schemas.VMStatusSchema(data) if validator.is_valid(): vm = VM_POOL.get(os.path.join(VM_PREFIX, data["uuid"])) - return json.dumps(str(vm)) + vm_value = vm.value.copy() + vm_value["ip"] = list(map(str, get_ip_addr(vm.mac, "br0"))) + vm.value = vm_value + print(vm.value) + return vm.value else: return validator.get_errors(), 400 @@ -97,7 +92,7 @@ class CreateImage(Resource): @staticmethod def post(): data = request.json - validator = CreateImageSchema(data) + validator = schemas.CreateImageSchema(data) if validator.is_valid(): file_entry = etcd_client.get(os.path.join(FILE_PREFIX, data["uuid"])) file_entry_value = json.loads(file_entry.value) @@ -121,10 +116,15 @@ class CreateImage(Resource): class ListPublicImages(Resource): @staticmethod def get(): - images = etcd_client.get_prefix(IMAGE_PREFIX) + images = etcd_client.get_prefix(IMAGE_PREFIX, value_in_json=True) r = {} + r["images"] = [] for image in images: - r[image.key.split("/")[-1]] = json.loads(image.value) + image_key = "{}:{}".format(image.value["store_name"], image.value["name"]) + r["images"].append({ + "name":image_key, + "status": image.value["status"] + }) return r, 200 @@ -132,7 +132,7 @@ class VMAction(Resource): @staticmethod def post(): data = request.json - validator = VmActionSchema(data) + validator = schemas.VmActionSchema(data) if validator.is_valid(): vm_entry = VM_POOL.get(os.path.join(VM_PREFIX, data["uuid"])) @@ -182,7 +182,7 @@ class VMMigration(Resource): @staticmethod def post(): data = request.json - validator = VmMigrationSchema(data) + validator = schemas.VmMigrationSchema(data) if validator.is_valid(): vm = VM_POOL.get(data["uuid"]) @@ -203,7 +203,7 @@ class ListUserVM(Resource): @staticmethod def get(): data = request.json - validator = OTPSchema(data) + validator = schemas.OTPSchema(data) if validator.is_valid(): vms = etcd_client.get_prefix(VM_PREFIX, value_in_json=True) @@ -235,7 +235,7 @@ class ListUserFiles(Resource): @staticmethod def get(): data = request.json - validator = OTPSchema(data) + validator = schemas.OTPSchema(data) if validator.is_valid(): files = etcd_client.get_prefix(FILE_PREFIX, value_in_json=True) @@ -257,7 +257,7 @@ class CreateHost(Resource): @staticmethod def post(): data = request.json - validator = CreateHostSchema(data) + validator = schemas.CreateHostSchema(data) if validator.is_valid(): host_key = os.path.join(HOST_PREFIX, uuid4().hex) host_entry = { @@ -292,7 +292,7 @@ class GetSSHKeys(Resource): @staticmethod def get(): data = request.json - validator = GetSSHSchema(data) + validator = schemas.GetSSHSchema(data) if validator.is_valid(): if not validator.key_name.value: @@ -321,7 +321,7 @@ class AddSSHKey(Resource): @staticmethod def post(): data = request.json - validator = AddSSHSchema(data) + validator = schemas.AddSSHSchema(data) if validator.is_valid(): # {user_prefix}/{realm}/{name}/key/{key_name} @@ -342,7 +342,7 @@ class RemoveSSHKey(Resource): @staticmethod def get(): data = request.json - validator = RemoveSSHSchema(data) + validator = schemas.RemoveSSHSchema(data) if validator.is_valid(): # {user_prefix}/{realm}/{name}/key/{key_name} diff --git a/api/schemas.py b/api/schemas.py index 8f366bf..b0e49b7 100755 --- a/api/schemas.py +++ b/api/schemas.py @@ -18,6 +18,8 @@ import json import os import bitmath +import helper + from ucloud_common.host import HostPool, HostStatus from ucloud_common.vm import VmPool, VMStatus @@ -207,22 +209,24 @@ class CreateVMSchema(OTPSchema): # Fields self.specs = Field("specs", dict, data.get("specs", KeyError)) self.vm_name = Field("vm_name", str, data.get("vm_name", KeyError)) - self.image_uuid = Field("image_uuid", str, data.get("image_uuid", KeyError)) + self.image = Field("image", str, data.get("image", KeyError)) # Validation - self.image_uuid.validation = self.image_uuid_validation + self.image.validation = self.image_validation self.vm_name.validation = self.vm_name_validation self.specs.validation = self.specs_validation - fields = [self.vm_name, self.image_uuid, self.specs] + fields = [self.vm_name, self.image, self.specs] super().__init__(data=data, fields=fields) - def image_uuid_validation(self): - images = client.get_prefix(IMAGE_PREFIX) - - if self.image_uuid.value not in [i.key.split("/")[-1] for i in images]: - self.add_error("Image UUID not valid") + def image_validation(self): + try: + image_uuid = helper.resolve_image_name(self.image.value, client) + except Exception as e: + self.add_error(str(e)) + else: + self.image_uuid = image_uuid def vm_name_validation(self): if resolve_vm_name(name=self.vm_name.value, owner=self.name.value): diff --git a/filescanner/main.py b/filescanner/main.py index 84ac53e..6495886 100755 --- a/filescanner/main.py +++ b/filescanner/main.py @@ -12,6 +12,7 @@ from uuid import uuid4 def getxattr(file, attr): + """Get specified user extended attribute (arg:attr) of a file (arg:file)""" try: attr = "user." + attr value = sp.check_output(['getfattr', file, @@ -24,25 +25,39 @@ def getxattr(file, attr): return value + def setxattr(file, attr, value): + """Set specified user extended attribute (arg:attr) equal to (arg:value) + of a file (arg:file)""" + attr = "user." + attr sp.check_output(['setfattr', file, '--name', attr, '--value', str(value)]) -def sha512sum(filename): - _sum = hashlib.sha512() - buffer_size = 2**16 - - with open(filename, "rb") as f: - while True: - data = f.read(buffer_size) - if not data: - break - _sum.update(data) - - return _sum.hexdigest() +def sha512sum(file: str): + """Use sha512sum utility to compute sha512 sum of arg:file + + IF arg:file does not exists: + raise FileNotFoundError exception + ELSE IF sum successfully computer: + return computed sha512 sum + ELSE: + return None + """ + if not isinstance(file, str): raise TypeError + try: + output = sp.check_output(["sha512sum", file], stderr=sp.PIPE) + except sp.CalledProcessError as e: + error = e.stderr.decode("utf-8") + if "No such file or directory" in error: + raise FileNotFoundError from None + else: + output = output.decode("utf-8").strip() + output = output.split(" ") + return output[0] + return None try: @@ -53,57 +68,61 @@ except Exception as e: print('Make sure you have getfattr and setfattr available') exit(1) +def main(): + BASE_DIR = config("BASE_DIR") -BASE_DIR = config("BASE_DIR") + FILE_PREFIX = config("FILE_PREFIX") -FILE_PREFIX = config("FILE_PREFIX") + etcd_client = Etcd3Wrapper(host=config("ETCD_URL")) -etcd_client = Etcd3Wrapper(host=config("ETCD_URL")) + # Recursively Get All Files and Folder below BASE_DIR + files = glob.glob("{}/**".format(BASE_DIR), recursive=True) -# Recursively Get All Files and Folder below BASE_DIR -files = glob.glob("{}/**".format(BASE_DIR), recursive=True) + # Retain only Files + files = list(filter(os.path.isfile, files)) -# Retain only Files -files = list(filter(os.path.isfile, files)) + untracked_files = list( + filter(lambda f: not bool(getxattr(f, "user.utracked")), files) + ) -untracked_files = list( - filter(lambda f: not bool(getxattr(f, "user.utracked")), files) -) + tracked_files = list( + filter(lambda f: f not in untracked_files, files) + ) + for file in untracked_files: + file_id = uuid4() -tracked_files = list( - filter(lambda f: f not in untracked_files, files) -) -for file in untracked_files: - file_id = uuid4() + # Get Username + owner = pathlib.Path(file).parts[3] + # Get Creation Date of File + # Here, we are assuming that ctime is creation time + # which is mostly not true. + creation_date = time.ctime(os.stat(file).st_ctime) - # Get Username - owner = pathlib.Path(file).parts[3] - # Get Creation Date of File - # Here, we are assuming that ctime is creation time - # which is mostly not true. - creation_date = time.ctime(os.stat(file).st_ctime) + # Get File Size + size = os.path.getsize(file) - # Get File Size - size = os.path.getsize(file) + # Compute sha512 sum + sha_sum = sha512sum(file) - # Compute sha512 sum - sha_sum = sha512sum(file) + # File Path excluding base and username + file_path = pathlib.Path(file).parts[4:] + file_path = os.path.join(*file_path) - # File Path excluding base and username - file_path = pathlib.Path(file).parts[4:] - file_path = os.path.join(*file_path) + # Create Entry + entry_key = os.path.join(FILE_PREFIX, str(file_id)) + entry_value = { + "filename": file_path, + "owner": owner, + "sha512sum": sha_sum, + "creation_date": creation_date, + "size": size + } - # Create Entry - entry_key = os.path.join(FILE_PREFIX, str(file_id)) - entry_value = { - "filename": file_path, - "owner": owner, - "sha512sum": sha_sum, - "creation_date": creation_date, - "size": size - } + print("Tracking {}".format(file)) + # Insert Entry + etcd_client.put(entry_key, entry_value, value_in_json=True) + setxattr(file, "user.utracked", True) - print("Tracking {}".format(file)) - # Insert Entry - etcd_client.put(entry_key, entry_value, value_in_json=True) - setxattr(file, "user.utracked", True) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/host/config.py b/host/config.py index 4191828..03cee29 100755 --- a/host/config.py +++ b/host/config.py @@ -16,7 +16,10 @@ logging.basicConfig( datefmt="%d-%b-%y %H:%M:%S", ) -etcd_client = Etcd3Wrapper(host=config("ETCD_URL")) +etcd_wrapper_args = () +etcd_wrapper_kwargs = {"host": config("ETCD_URL")} + +etcd_client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) HOST_PREFIX = config("HOST_PREFIX") VM_PREFIX = config("VM_PREFIX") diff --git a/host/main.py b/host/main.py index 8fe73c9..b86e88e 100755 --- a/host/main.py +++ b/host/main.py @@ -1,27 +1,32 @@ import argparse -import threading +# import threading import time import os import sys import virtualmachine +import multiprocessing as mp from ucloud_common.host import HostEntry from ucloud_common.request import RequestEntry, RequestType from config import (vm_pool, host_pool, request_pool, etcd_client, logging, running_vms, - REQUEST_PREFIX, WITHOUT_CEPH) + etcd_wrapper_args, etcd_wrapper_kwargs, + REQUEST_PREFIX, HOST_PREFIX, + WITHOUT_CEPH, VM_DIR, HostPool) +from etcd3_wrapper import Etcd3Wrapper +import etcd3 +def update_heartbeat(host): + client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) + host_pool = HostPool(client, HOST_PREFIX) + this_host = host_pool.get(host) -def update_heartbeat(host: HostEntry): while True: - host.update_heartbeat() - host_pool.put(host) + this_host.update_heartbeat() + host_pool.put(this_host) time.sleep(10) - logging.info("Updated last heartbeat time %s", host.last_heartbeat) - - def maintenance(host): # To capture vm running according to running_vms list @@ -66,16 +71,25 @@ def main(): argparser.add_argument("hostname", help="Name of this host. e.g /v1/host/1") args = argparser.parse_args() + assert WITHOUT_CEPH and os.path.isdir(VM_DIR), ( + "You have set WITHOUT_CEPH to True. So, the vm directory mentioned" + " in .env file must exists. But, it don't." ) + + mp.set_start_method('spawn') + heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(args.hostname,)) + + host_pool = HostPool(etcd_client, HOST_PREFIX) host = host_pool.get(args.hostname) - if not host: - print("No Such Host") - exit(1) - - if WITHOUT_CEPH and not os.path.isdir("/var/vm"): - print("You have set WITHOUT_CEPH to True. So, the /var/vm must exists. But, it don't") - sys.exit(1) + assert host, "No such host" + try: + heartbeat_updating_process.start() + except Exception as e: + logging.info("No Need To Go Further. Our heartbeat updating mechanism is not working") + logging.exception(e) + exit(-1) + logging.info("%s Session Started %s", '*' * 5, '*' * 5) # It is seen that under heavy load, timeout event doesn't come @@ -86,13 +100,6 @@ def main(): # update the heart beat in a predictive manner we start Heart # beat updating mechanism in separated thread - heartbeat_updating_thread = threading.Thread(target=update_heartbeat, args=(host,)) - try: - heartbeat_updating_thread.start() - except Exception as e: - logging.info("No Need To Go Further. Our heartbeat updating mechanism is not working") - logging.exception(e) - exit(-1) for events_iterator in [ etcd_client.get_prefix(REQUEST_PREFIX, value_in_json=True), @@ -134,4 +141,6 @@ def main(): logging.info("Running VMs %s", running_vms) -main() +if __name__ == "__main__": + main() + diff --git a/imagescanner/main.py b/imagescanner/main.py index f0956ac..146e756 100755 --- a/imagescanner/main.py +++ b/imagescanner/main.py @@ -19,90 +19,96 @@ def qemu_img_type(path): qemu_img_info = json.loads(qemu_img_info.decode("utf-8")) return qemu_img_info["format"] -# If you are using WITHOUT_CEPH FLAG in .env -# then please make sure that IMAGE_DIR directory -# exists otherwise this script would fail -if WITHOUT_CEPH and not os.path.isdir(IMAGE_DIR): - print("You have set WITHOUT_CEPH to True. So," - "the {} must exists. But, it don't".format(IMAGE_DIR)) - sys.exit(1) - -try: - subprocess.check_output(['which', 'qemu-img']) -except Exception: - print("qemu-img missing") - sys.exit(1) - -# We want to get images entries that requests images to be created -images = client.get_prefix(IMAGE_PREFIX, value_in_json=True) -images_to_be_created = list(filter(lambda im: im.value['status'] == 'TO_BE_CREATED', images)) - -for image in images_to_be_created: - try: - image_uuid = image.key.split('/')[-1] - image_owner = image.value['owner'] - image_filename = image.value['filename'] - image_store_name = image.value['store_name'] - image_full_path = os.path.join(BASE_PATH, image_owner, image_filename) - - image_stores = client.get_prefix(IMAGE_STORE_PREFIX, value_in_json=True) - user_image_store = next(filter( - lambda s, store_name=image_store_name: s.value["name"] == store_name, - image_stores - )) - - image_store_pool = user_image_store.value['attributes']['pool'] - - except Exception as e: - logging.exception(e) - else: - # At least our basic data is available - - qemu_img_convert_command = ["qemu-img", "convert", "-f", "qcow2", - "-O", "raw", image_full_path, "image.raw"] - - - if WITHOUT_CEPH: - image_import_command = ["mv", "image.raw", os.path.join(IMAGE_DIR, image_uuid)] - snapshot_creation_command = ["true"] - snapshot_protect_command = ["true"] - else: - image_import_command = ["rbd", "import", "image.raw", - "{}/{}".format(image_store_pool, image_uuid)] - snapshot_creation_command = ["rbd", "snap", "create", - "{}/{}@protected".format(image_store_pool, image_uuid)] - snapshot_protect_command = ["rbd", "snap", "protect", - "{}/{}@protected".format(image_store_pool, image_uuid)] - - - # First check whether the image is qcow2 - - if qemu_img_type(image_full_path) == "qcow2": - try: - # Convert .qcow2 to .raw - subprocess.check_output(qemu_img_convert_command) - - # Import image either to ceph/filesystem - subprocess.check_output(image_import_command) - - # Create and Protect Snapshot - subprocess.check_output(snapshot_creation_command) - subprocess.check_output(snapshot_protect_command) - - except Exception as e: - logging.exception(e) - - else: - # Everything is successfully done - image.value["status"] = "CREATED" - client.put(image.key, json.dumps(image.value)) - else: - # The user provided image is either not found or of invalid format - image.value["status"] = "INVALID_IMAGE" - client.put(image.key, json.dumps(image.value)) +def main(): + # If you are using WITHOUT_CEPH FLAG in .env + # then please make sure that IMAGE_DIR directory + # exists otherwise this script would fail + if WITHOUT_CEPH and not os.path.isdir(IMAGE_DIR): + print("You have set WITHOUT_CEPH to True. So," + "the {} must exists. But, it don't".format(IMAGE_DIR)) + sys.exit(1) try: - os.remove("image.raw") + subprocess.check_output(['which', 'qemu-img']) except Exception: - pass + print("qemu-img missing") + sys.exit(1) + + # We want to get images entries that requests images to be created + images = client.get_prefix(IMAGE_PREFIX, value_in_json=True) + images_to_be_created = list(filter(lambda im: im.value['status'] == 'TO_BE_CREATED', images)) + + for image in images_to_be_created: + try: + image_uuid = image.key.split('/')[-1] + image_owner = image.value['owner'] + image_filename = image.value['filename'] + image_store_name = image.value['store_name'] + image_full_path = os.path.join(BASE_PATH, image_owner, image_filename) + + image_stores = client.get_prefix(IMAGE_STORE_PREFIX, value_in_json=True) + user_image_store = next(filter( + lambda s, store_name=image_store_name: s.value["name"] == store_name, + image_stores + )) + + image_store_pool = user_image_store.value['attributes']['pool'] + + except Exception as e: + logging.exception(e) + else: + # At least our basic data is available + + qemu_img_convert_command = ["qemu-img", "convert", "-f", "qcow2", + "-O", "raw", image_full_path, "image.raw"] + + + if WITHOUT_CEPH: + image_import_command = ["mv", "image.raw", os.path.join(IMAGE_DIR, image_uuid)] + snapshot_creation_command = ["true"] + snapshot_protect_command = ["true"] + else: + image_import_command = ["rbd", "import", "image.raw", + "{}/{}".format(image_store_pool, image_uuid)] + snapshot_creation_command = ["rbd", "snap", "create", + "{}/{}@protected".format(image_store_pool, image_uuid)] + snapshot_protect_command = ["rbd", "snap", "protect", + "{}/{}@protected".format(image_store_pool, image_uuid)] + + + # First check whether the image is qcow2 + + if qemu_img_type(image_full_path) == "qcow2": + try: + # Convert .qcow2 to .raw + subprocess.check_output(qemu_img_convert_command) + + # Import image either to ceph/filesystem + subprocess.check_output(image_import_command) + + # Create and Protect Snapshot + subprocess.check_output(snapshot_creation_command) + subprocess.check_output(snapshot_protect_command) + + except Exception as e: + logging.exception(e) + + else: + # Everything is successfully done + image.value["status"] = "CREATED" + client.put(image.key, json.dumps(image.value)) + else: + # The user provided image is either not found or of invalid format + image.value["status"] = "INVALID_IMAGE" + client.put(image.key, json.dumps(image.value)) + + + try: + os.remove("image.raw") + except Exception: + pass + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/metadata/config.py b/metadata/config.py index 0df4102..05cc113 100644 --- a/metadata/config.py +++ b/metadata/config.py @@ -15,6 +15,7 @@ logging.basicConfig( VM_PREFIX = config("VM_PREFIX") +USER_PREFIX = config("USER_PREFIX") etcd_client = Etcd3Wrapper(host=config("ETCD_URL")) diff --git a/metadata/main.py b/metadata/main.py index d9a0bb7..1c67768 100644 --- a/metadata/main.py +++ b/metadata/main.py @@ -1,6 +1,8 @@ +import os + from flask import Flask, request from flask_restful import Resource, Api -from config import etcd_client, VM_POOL +from config import etcd_client, VM_POOL, USER_PREFIX app = Flask(__name__) api = Api(app) @@ -29,7 +31,6 @@ def ipv62mac(ipv6): mac_parts[0] = '%02x' % (int(mac_parts[0], 16) ^ 2) del mac_parts[4] del mac_parts[3] - return ':'.join(mac_parts) diff --git a/scheduler/main.py b/scheduler/main.py index 4ce178f..6e0ba90 100755 --- a/scheduler/main.py +++ b/scheduler/main.py @@ -14,8 +14,9 @@ from helper import (get_suitable_host, dead_host_mitigation, dead_host_detection assign_host, NoSuitableHostFound) - def main(): + logging.info("%s SESSION STARTED %s", '*' * 5, '*' * 5) + pending_vms = [] for request_iterator in [ @@ -85,5 +86,5 @@ def main(): logging.info("No Resource Left. Emailing admin....") -logging.info("%s SESSION STARTED %s", '*' * 5, '*' * 5) -main() +if __name__ == "__main__": + main() From da5a600ccb3434cae1b1c86e182bc541f83bfba5 Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 11 Nov 2019 23:42:57 +0500 Subject: [PATCH 007/284] single node,w/o ceph networking implemented --- .gitignore | 1 + Pipfile | 2 + Pipfile.lock | 331 +++++++++++++++------- api/common_fields.py | 8 +- api/config.py | 1 + api/helper.py | 23 ++ api/main.py | 54 +++- api/schemas.py | 51 +++- docs/Makefile | 20 ++ docs/make.bat | 35 +++ docs/source/conf.py | 52 ++++ docs/source/index.rst | 21 ++ docs/source/introduction/installation.rst | 200 +++++++++++++ docs/source/introduction/introduction.rst | 23 ++ host/config.py | 1 + host/main.py | 1 - host/virtualmachine.py | 83 +++++- network/create-bridge.sh | 23 ++ network/create-tap.sh | 22 ++ network/create-vxlan-on-dev.sh | 19 -- network/create-vxlan.sh | 26 ++ scheduler/helper.py | 13 +- scheduler/main.py | 3 + 23 files changed, 866 insertions(+), 147 deletions(-) create mode 100644 docs/Makefile create mode 100644 docs/make.bat create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst create mode 100644 docs/source/introduction/installation.rst create mode 100644 docs/source/introduction/introduction.rst create mode 100755 network/create-bridge.sh create mode 100755 network/create-tap.sh delete mode 100644 network/create-vxlan-on-dev.sh create mode 100755 network/create-vxlan.sh diff --git a/.gitignore b/.gitignore index bfdbce1..690d98e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ __pycache__ +docs/build */log.txt \ No newline at end of file diff --git a/Pipfile b/Pipfile index 5db945c..5aba57b 100644 --- a/Pipfile +++ b/Pipfile @@ -17,6 +17,8 @@ etcd3-wrapper = {editable = true,git = "git+https://code.ungleich.ch/ungleich-pu python-etcd3 = {editable = true,git = "git+https://github.com/kragniz/python-etcd3.git"} pyotp = "*" sshtunnel = "*" +helper = "*" +sphinx = "*" [requires] python_version = "3.5" diff --git a/Pipfile.lock b/Pipfile.lock index 62c17ca..6167f76 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b7a8409bec451e017440f063d8436fe66b18affcde7ad5497b433191ae465a52" + "sha256": "45db72f1a666be82e7dc044ced7e7ad7a5b5a6efbb8b8103e6ad04c93a7d017a" }, "pipfile-spec": 6, "requires": { @@ -16,6 +16,13 @@ ] }, "default": { + "alabaster": { + "hashes": [ + "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", + "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02" + ], + "version": "==0.7.12" + }, "aniso8601": { "hashes": [ "sha256:529dcb1f5f26ee0df6c0a1ee84b7b27197c3c50fc3a6321d66c544689237d072", @@ -23,6 +30,13 @@ ], "version": "==8.0.0" }, + "babel": { + "hashes": [ + "sha256:af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab", + "sha256:e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28" + ], + "version": "==2.7.0" + }, "bcrypt": { "hashes": [ "sha256:0258f143f3de96b7c14f762c770f5fc56ccd72f8a1857a451c1cd9a655d9ac89", @@ -62,40 +76,41 @@ }, "cffi": { "hashes": [ - "sha256:00d890313797d9fe4420506613384b43099ad7d2b905c0752dbcc3a6f14d80fa", - "sha256:0cf9e550ac6c5e57b713437e2f4ac2d7fd0cd10336525a27224f5fc1ec2ee59a", - "sha256:0ea23c9c0cdd6778146a50d867d6405693ac3b80a68829966c98dd5e1bbae400", - "sha256:193697c2918ecdb3865acf6557cddf5076bb39f1f654975e087b67efdff83365", - "sha256:1ae14b542bf3b35e5229439c35653d2ef7d8316c1fffb980f9b7647e544baa98", - "sha256:1e389e069450609c6ffa37f21f40cce36f9be7643bbe5051ab1de99d5a779526", - "sha256:263242b6ace7f9cd4ea401428d2d45066b49a700852334fd55311bde36dcda14", - "sha256:33142ae9807665fa6511cfa9857132b2c3ee6ddffb012b3f0933fc11e1e830d5", - "sha256:364f8404034ae1b232335d8c7f7b57deac566f148f7222cef78cf8ae28ef764e", - "sha256:47368f69fe6529f8f49a5d146ddee713fc9057e31d61e8b6dc86a6a5e38cecc1", - "sha256:4895640844f17bec32943995dc8c96989226974dfeb9dd121cc45d36e0d0c434", - "sha256:558b3afef987cf4b17abd849e7bedf64ee12b28175d564d05b628a0f9355599b", - "sha256:5ba86e1d80d458b338bda676fd9f9d68cb4e7a03819632969cf6d46b01a26730", - "sha256:63424daa6955e6b4c70dc2755897f5be1d719eabe71b2625948b222775ed5c43", - "sha256:6381a7d8b1ebd0bc27c3bc85bc1bfadbb6e6f756b4d4db0aa1425c3719ba26b4", - "sha256:6381ab708158c4e1639da1f2a7679a9bbe3e5a776fc6d1fd808076f0e3145331", - "sha256:6fd58366747debfa5e6163ada468a90788411f10c92597d3b0a912d07e580c36", - "sha256:728ec653964655d65408949b07f9b2219df78badd601d6c49e28d604efe40599", - "sha256:7cfcfda59ef1f95b9f729c56fe8a4041899f96b72685d36ef16a3440a0f85da8", - "sha256:819f8d5197c2684524637f940445c06e003c4a541f9983fd30d6deaa2a5487d8", - "sha256:825ecffd9574557590e3225560a8a9d751f6ffe4a49e3c40918c9969b93395fa", - "sha256:8a2bcae2258d00fcfc96a9bde4a6177bc4274fe033f79311c5dd3d3148c26518", - "sha256:9009e917d8f5ef780c2626e29b6bc126f4cb2a4d43ca67aa2b40f2a5d6385e78", - "sha256:9c77564a51d4d914ed5af096cd9843d90c45b784b511723bd46a8a9d09cf16fc", - "sha256:a19089fa74ed19c4fe96502a291cfdb89223a9705b1d73b3005df4256976142e", - "sha256:a40ed527bffa2b7ebe07acc5a3f782da072e262ca994b4f2085100b5a444bbb2", - "sha256:b8f09f21544b9899defb09afbdaeb200e6a87a2b8e604892940044cf94444644", - "sha256:bb75ba21d5716abc41af16eac1145ab2e471deedde1f22c6f99bd9f995504df0", - "sha256:e22a00c0c81ffcecaf07c2bfb3672fa372c50e2bd1024ffee0da191c1b27fc71", - "sha256:e55b5a746fb77f10c83e8af081979351722f6ea48facea79d470b3731c7b2891", - "sha256:ec2fa3ee81707a5232bf2dfbd6623fdb278e070d596effc7e2d788f2ada71a05", - "sha256:fd82eb4694be712fcae03c717ca2e0fc720657ac226b80bbb597e971fc6928c2" + "sha256:0b49274afc941c626b605fb59b59c3485c17dc776dc3cc7cc14aca74cc19cc42", + "sha256:0e3ea92942cb1168e38c05c1d56b0527ce31f1a370f6117f1d490b8dcd6b3a04", + "sha256:135f69aecbf4517d5b3d6429207b2dff49c876be724ac0c8bf8e1ea99df3d7e5", + "sha256:19db0cdd6e516f13329cba4903368bff9bb5a9331d3410b1b448daaadc495e54", + "sha256:2781e9ad0e9d47173c0093321bb5435a9dfae0ed6a762aabafa13108f5f7b2ba", + "sha256:291f7c42e21d72144bb1c1b2e825ec60f46d0a7468f5346841860454c7aa8f57", + "sha256:2c5e309ec482556397cb21ede0350c5e82f0eb2621de04b2633588d118da4396", + "sha256:2e9c80a8c3344a92cb04661115898a9129c074f7ab82011ef4b612f645939f12", + "sha256:32a262e2b90ffcfdd97c7a5e24a6012a43c61f1f5a57789ad80af1d26c6acd97", + "sha256:3c9fff570f13480b201e9ab69453108f6d98244a7f495e91b6c654a47486ba43", + "sha256:415bdc7ca8c1c634a6d7163d43fb0ea885a07e9618a64bda407e04b04333b7db", + "sha256:42194f54c11abc8583417a7cf4eaff544ce0de8187abaf5d29029c91b1725ad3", + "sha256:4424e42199e86b21fc4db83bd76909a6fc2a2aefb352cb5414833c030f6ed71b", + "sha256:4a43c91840bda5f55249413037b7a9b79c90b1184ed504883b72c4df70778579", + "sha256:599a1e8ff057ac530c9ad1778293c665cb81a791421f46922d80a86473c13346", + "sha256:5c4fae4e9cdd18c82ba3a134be256e98dc0596af1e7285a3d2602c97dcfa5159", + "sha256:5ecfa867dea6fabe2a58f03ac9186ea64da1386af2159196da51c4904e11d652", + "sha256:62f2578358d3a92e4ab2d830cd1c2049c9c0d0e6d3c58322993cc341bdeac22e", + "sha256:6471a82d5abea994e38d2c2abc77164b4f7fbaaf80261cb98394d5793f11b12a", + "sha256:6d4f18483d040e18546108eb13b1dfa1000a089bcf8529e30346116ea6240506", + "sha256:71a608532ab3bd26223c8d841dde43f3516aa5d2bf37b50ac410bb5e99053e8f", + "sha256:74a1d8c85fb6ff0b30fbfa8ad0ac23cd601a138f7509dc617ebc65ef305bb98d", + "sha256:7b93a885bb13073afb0aa73ad82059a4c41f4b7d8eb8368980448b52d4c7dc2c", + "sha256:7d4751da932caaec419d514eaa4215eaf14b612cff66398dd51129ac22680b20", + "sha256:7f627141a26b551bdebbc4855c1157feeef18241b4b8366ed22a5c7d672ef858", + "sha256:8169cf44dd8f9071b2b9248c35fc35e8677451c52f795daa2bb4643f32a540bc", + "sha256:aa00d66c0fab27373ae44ae26a66a9e43ff2a678bf63a9c7c1a9a4d61172827a", + "sha256:ccb032fda0873254380aa2bfad2582aedc2959186cce61e3a17abc1a55ff89c3", + "sha256:d754f39e0d1603b5b24a7f8484b22d2904fa551fe865fd0d4c3332f078d20d4e", + "sha256:d75c461e20e29afc0aee7172a0950157c704ff0dd51613506bd7d82b718e7410", + "sha256:dcd65317dd15bc0451f3e01c80da2216a31916bdcffd6221ca1202d96584aa25", + "sha256:e570d3ab32e2c2861c4ebe6ffcad6a8abf9347432a37608fe1fbd157b3f0036b", + "sha256:fd43a88e045cf992ed09fa724b5315b790525f2676883a6ea64e3263bae6549d" ], - "version": "==1.13.1" + "version": "==1.13.2" }, "chardet": { "hashes": [ @@ -137,6 +152,14 @@ ], "version": "==2.8" }, + "docutils": { + "hashes": [ + "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0", + "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827", + "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99" + ], + "version": "==0.15.2" + }, "etcd3-wrapper": { "editable": true, "git": "https://code.ungleich.ch/ungleich-public/etcd3_wrapper.git", @@ -160,56 +183,64 @@ }, "grpcio": { "hashes": [ - "sha256:01cb705eafba1108e2a947ba0457da4f6a1e8142c729fc61702b5fdd11009eb1", - "sha256:0b5a79e29f167d3cd06faad6b15babbc2661066daaacf79373c3a8e67ca1fca1", - "sha256:1097a61a0e97b3580642e6e1460a3a1f1ba1815e2a70d6057173bcc495417076", - "sha256:13970e665a4ec4cec7d067d7d3504a0398c657d91d26c581144ad9044e429c9a", - "sha256:1557817cea6e0b87fad2a3e20da385170efb03a313db164e8078955add2dfa1b", - "sha256:1b0fb036a2f9dd93d9a35c57c26420eeb4b571fcb14b51cddf5b1e73ea5d882b", - "sha256:24d9e58d08e8cd545d8a3247a18654aff0e5e60414701696a8098fbb0d792b75", - "sha256:2c38b586163d2b91567fe5e6d9e7798f792012365adc838a64b66b22dce3f4d4", - "sha256:2df3ab4348507de60e1cbf75196403df1b9b4c4d4dc5bd11ac4eb63c46f691c7", - "sha256:32f70f7c90454ea568b868af2e96616743718d9233d23f62407e98caed81dfbf", - "sha256:3af2a49d576820045c9c880ff29a5a96d020fe31b35d248519bfc6ccb8be4eac", - "sha256:4ff7d63800a63db031ebac6a6f581ae84877c959401c24c28f2cc51fd36c47ad", - "sha256:502aaa8be56f0ae69cda66bc27e1fb5531ceaa27ca515ec3c34f6178b1297180", - "sha256:55358ce3ec283222e435f7dbc6603521438458f3c65f7c1cb33b8dabf56d70d8", - "sha256:5583b01c67f85fa64a2c3fb085e5517c88b9c1500a2cce12d473cd99d0ed2e49", - "sha256:58d9a5557d3eb7b734a3cea8b16c891099a522b3953a45a30bd4c034f75fc913", - "sha256:5911f042c4ab177757eec5bcb4e2e9a2e823d888835d24577321bf55f02938fa", - "sha256:5e16ea922f4e5017c04fd94e2639b1006e03097e9dd0cbb7a1c852af3ea8bf2e", - "sha256:656e19d3f1b9050ee01b457f92838a9679d7cf84c995f708780f44484048705e", - "sha256:6a1435449a82008c451c7e1a82a834387b9108f9a8d27910f86e7c482f5568e9", - "sha256:6ff02ca6cbed0ddb76e93ba0f8beb6a8c77d83a84eb7cafe2ae3399a8b9d69ea", - "sha256:76de68f60102f333bf4817f38e81ecbee68b850f5a5da9f355235e948ac40981", - "sha256:7c6d7ddd50fc6548ea1dfe09c62509c4f95b8b40082287747be05aa8feb15ee2", - "sha256:836b9d29507de729129e363276fe7c7d6a34c7961e0f155787025552b15d22c0", - "sha256:869242b2baf8a888a4fe0548f86abc47cb4b48bdfd76ae62d6456e939c202e65", - "sha256:8954b24bd08641d906ee50b2d638efc76df893fbd0913149b80484fd0eac40c9", - "sha256:8cdea65d1abb2e698420db8daf20c8d272fbd9d96a51b26a713c1c76f237d181", - "sha256:90161840b4fe9636f91ed0d3ea1e7e615e488cbea4e77594c889e5f3d7a776db", - "sha256:90fb6316b4d7d36700c40db4335902b78dcae13b5466673c21fd3b08a3c1b0c6", - "sha256:91b34f58db2611c9a93ecf751028f97fba1f06e65f49b38f272f6aa5d2977331", - "sha256:9474944a96a33eb8734fa8dc5805403d57973a3526204a5e1c1780d02e0572b6", - "sha256:9a36275db2a4774ac16c6822e7af816ee048071d5030b4c035fd53942b361935", - "sha256:9cbe26e2976b994c5f7c2d35a63354674d6ca0ce62f5b513f078bf63c1745229", - "sha256:9eaeabb3c0eecd6ddd0c16767fd12d130e2cebb8c2618f959a278b1ff336ddc3", - "sha256:a2bc7e10ebcf4be503ae427f9887e75c0cc24e88ce467a8e6eaca6bd2862406e", - "sha256:a5b42e6292ba51b8e67e09fc256963ba4ca9c04026de004d2fe59cc17e3c3776", - "sha256:bd6ec1233c86c0b9bb5d03ec30dbe3ffbfa53335790320d99a7ae9018c5450f2", - "sha256:bef57530816af54d66b1f4c70a8f851f320cb6f84d4b5a0b422b0e9811ea4e59", - "sha256:c146a63eaadc6589b732780061f3c94cd0574388d372baccbb3c1597a9ebdb7a", - "sha256:c2efd3b130dc639d615b6f58980e1bfd1b177ad821f30827afa5001aa30ddd48", - "sha256:c888b18f7392e6cc79a33a803e7ebd7890ac3318f571fca6b356526f35b53b12", - "sha256:ca30721fda297ae22f16bc37aa7ed244970ddfdcb98247570cdd26daaad4665e", - "sha256:cf5f5340dd682ab034baa52f423a0f91326489c262ac9617fa06309ec05880e9", - "sha256:d0726aa0d9b57c56985db5952e90fb1033a317074f2877db5307cdd6eede1564", - "sha256:df442945b2dd6f8ae0e20b403e0fd4548cd5c2aad69200047cc3251257b78f65", - "sha256:e08e758c31919d167c0867539bd3b2441629ef00aa595e3ea2b635273659f40a", - "sha256:e4864339deeeaefaad34dd3a432ee618a039fca28efb292949c855e00878203c", - "sha256:f4cd049cb94d9f517b1cab5668a3b345968beba093bc79a637e671000b3540ec" + "sha256:0419ae5a45f49c7c40d9ae77ae4de9442431b7822851dfbbe56ee0eacb5e5654", + "sha256:1e8631eeee0fb0b4230aeb135e4890035f6ef9159c2a3555fa184468e325691a", + "sha256:24db2fa5438f3815a4edb7a189035051760ca6aa2b0b70a6a948b28bfc63c76b", + "sha256:2adb1cdb7d33e91069517b41249622710a94a1faece1fed31cd36904e4201cde", + "sha256:2cd51f35692b551aeb1fdeb7a256c7c558f6d78fcddff00640942d42f7aeba5f", + "sha256:3247834d24964589f8c2b121b40cd61319b3c2e8d744a6a82008643ef8a378b1", + "sha256:3433cb848b4209717722b62392e575a77a52a34d67c6730138102abc0a441685", + "sha256:39671b7ff77a962bd745746d9d2292c8ed227c5748f16598d16d8631d17dd7e5", + "sha256:40a0b8b2e6f6dd630f8b267eede2f40a848963d0f3c40b1b1f453a4a870f679e", + "sha256:40f9a74c7aa210b3e76eb1c9d56aa8d08722b73426a77626967019df9bbac287", + "sha256:423f76aa504c84cb94594fb88b8a24027c887f1c488cf58f2173f22f4fbd046c", + "sha256:43bd04cec72281a96eb361e1b0232f0f542b46da50bcfe72ef7e5a1b41d00cb3", + "sha256:43e38762635c09e24885d15e3a8e374b72d105d4178ee2cc9491855a8da9c380", + "sha256:4413b11c2385180d7de03add6c8845dd66692b148d36e27ec8c9ef537b2553a1", + "sha256:4450352a87094fd58daf468b04c65a9fa19ad11a0ac8ac7b7ff17d46f873cbc1", + "sha256:49ffda04a6e44de028b3b786278ac9a70043e7905c3eea29eed88b6524d53a29", + "sha256:4a38c4dde4c9120deef43aaabaa44f19186c98659ce554c29788c4071ab2f0a4", + "sha256:50b1febdfd21e2144b56a9aa226829e93a79c354ef22a4e5b013d9965e1ec0ed", + "sha256:559b1a3a8be7395ded2943ea6c2135d096f8cc7039d6d12127110b6496f251fe", + "sha256:5de86c182667ec68cf84019aa0d8ceccf01d352cdca19bf9e373725204bdbf50", + "sha256:5fc069bb481fe3fad0ba24d3baaf69e22dfa6cc1b63290e6dfeaf4ac1e996fb7", + "sha256:6a19d654da49516296515d6f65de4bbcbd734bc57913b21a610cfc45e6df3ff1", + "sha256:7535b3e52f498270e7877dde1c8944d6b7720e93e2e66b89c82a11447b5818f5", + "sha256:7c4e495bcabc308198b8962e60ca12f53b27eb8f03a21ac1d2d711d6dd9ecfca", + "sha256:8a8fc4a0220367cb8370cedac02272d574079ccc32bffbb34d53aaf9e38b5060", + "sha256:8b008515e067232838daca020d1af628bf6520c8cc338bf383284efe6d8bd083", + "sha256:8d1684258e1385e459418f3429e107eec5fb3d75e1f5a8c52e5946b3f329d6ea", + "sha256:8eb5d54b87fb561dc2e00a5c5226c33ffe8dbc13f2e4033a412bafb7b37b194d", + "sha256:94cdef0c61bd014bb7af495e21a1c3a369dd0399c3cd1965b1502043f5c88d94", + "sha256:9d9f3be69c7a5e84c3549a8c4403fa9ac7672da456863d21e390b2bbf45ccad1", + "sha256:9fb6fb5975a448169756da2d124a1beb38c0924ff6c0306d883b6848a9980f38", + "sha256:a5eaae8700b87144d7dfb475aa4675e500ff707292caba3deff41609ddc5b845", + "sha256:aaeac2d552772b76d24eaff67a5d2325bc5205c74c0d4f9fbe71685d4a971db2", + "sha256:bb611e447559b3b5665e12a7da5160c0de6876097f62bf1d23ba66911564868e", + "sha256:bc0d41f4eb07da8b8d3ea85e50b62f6491ab313834db86ae2345be07536a4e5a", + "sha256:bf51051c129b847d1bb63a9b0826346b5f52fb821b15fe5e0d5ef86f268510f5", + "sha256:c948c034d8997526011960db54f512756fb0b4be1b81140a15b4ef094c6594a4", + "sha256:d435a01334157c3b126b4ee5141401d44bdc8440993b18b05e2f267a6647f92d", + "sha256:d46c1f95672b73288e08cdca181e14e84c6229b5879561b7b8cfd48374e09287", + "sha256:d5d58309b42064228b16b0311ff715d6c6e20230e81b35e8d0c8cfa1bbdecad8", + "sha256:dc6e2e91365a1dd6314d615d80291159c7981928b88a4c65654e3fefac83a836", + "sha256:e0dfb5f7a39029a6cbec23affa923b22a2c02207960fd66f109e01d6f632c1eb", + "sha256:eb4bf58d381b1373bd21d50837a53953d625d1693f1b58fed12743c75d3dd321", + "sha256:ebb211a85248dbc396b29320273c1ffde484b898852432613e8df0164c091006", + "sha256:ec759ece4786ae993a5b7dc3b3dead6e9375d89a6c65dfd6860076d2eb2abe7b", + "sha256:f55108397a8fa164268238c3e69cc134e945d1f693572a2f05a028b8d0d2b837", + "sha256:f6c706866d424ff285b85a02de7bbe5ed0ace227766b2c42cbe12f3d9ea5a8aa", + "sha256:f8370ad332b36fbad117440faf0dd4b910e80b9c49db5648afd337abdde9a1b6" ], - "version": "==1.24.3" + "version": "==1.25.0" + }, + "helper": { + "hashes": [ + "sha256:33d4a58046018fea9f46da5835a768feb9beab3528d4025d063bf354c4a19750", + "sha256:a63d4a9255ad5071043e7e4ab8000a512627f1db958b1941b63c7d75e56ea65c" + ], + "index": "pypi", + "version": "==2.4.2" }, "idna": { "hashes": [ @@ -218,6 +249,13 @@ ], "version": "==2.8" }, + "imagesize": { + "hashes": [ + "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8", + "sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5" + ], + "version": "==1.1.0" + }, "itsdangerous": { "hashes": [ "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", @@ -265,6 +303,13 @@ ], "version": "==1.1.1" }, + "packaging": { + "hashes": [ + "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47", + "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108" + ], + "version": "==19.2" + }, "paramiko": { "hashes": [ "sha256:99f0179bdc176281d21961a003ffdb2ec369daac1a1007241f53374e376576cf", @@ -299,6 +344,13 @@ ], "version": "==2.19" }, + "pygments": { + "hashes": [ + "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", + "sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297" + ], + "version": "==2.4.2" + }, "pynacl": { "hashes": [ "sha256:05c26f93964373fc0abe332676cb6735f0ecad27711035b9472751faa8521255", @@ -333,6 +385,13 @@ "index": "pypi", "version": "==2.3.0" }, + "pyparsing": { + "hashes": [ + "sha256:20f995ecd72f2a1f4bf6b072b63b22e2eb457836601e76d6e5dfcd75436acc1f", + "sha256:4ca62001be367f01bd3e92ecbb79070272a9d4964dce6a48a82ff0b8bc7e683a" + ], + "version": "==2.4.5" + }, "python-decouple": { "hashes": [ "sha256:1317df14b43efee4337a4aa02914bf004f010cd56d6c4bd894e6474ec8c4fe2d" @@ -352,6 +411,24 @@ ], "version": "==2019.3" }, + "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" + }, "requests": { "hashes": [ "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", @@ -362,10 +439,67 @@ }, "six": { "hashes": [ - "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", - "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", + "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" ], - "version": "==1.12.0" + "version": "==1.13.0" + }, + "snowballstemmer": { + "hashes": [ + "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", + "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" + ], + "version": "==2.0.0" + }, + "sphinx": { + "hashes": [ + "sha256:31088dfb95359384b1005619827eaee3056243798c62724fd3fa4b84ee4d71bd", + "sha256:52286a0b9d7caa31efee301ec4300dbdab23c3b05da1c9024b4e84896fb73d79" + ], + "index": "pypi", + "version": "==2.2.1" + }, + "sphinxcontrib-applehelp": { + "hashes": [ + "sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897", + "sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d" + ], + "version": "==1.0.1" + }, + "sphinxcontrib-devhelp": { + "hashes": [ + "sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34", + "sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981" + ], + "version": "==1.0.1" + }, + "sphinxcontrib-htmlhelp": { + "hashes": [ + "sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422", + "sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7" + ], + "version": "==1.0.2" + }, + "sphinxcontrib-jsmath": { + "hashes": [ + "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", + "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" + ], + "version": "==1.0.1" + }, + "sphinxcontrib-qthelp": { + "hashes": [ + "sha256:513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20", + "sha256:79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f" + ], + "version": "==1.0.2" + }, + "sphinxcontrib-serializinghtml": { + "hashes": [ + "sha256:c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227", + "sha256:db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768" + ], + "version": "==1.1.3" }, "sshtunnel": { "hashes": [ @@ -376,10 +510,10 @@ }, "tenacity": { "hashes": [ - "sha256:3a916e734559f1baa2cab965ee00061540c41db71c3bf25375b81540a19758fc", - "sha256:e664bd94f088b17f46da33255ae33911ca6a0fe04b156d334b601a4ef66d3c5f" + "sha256:72f397c2bb1887e048726603f3f629ea16f88cb3e61e4ed3c57e98582b8e3571", + "sha256:947e728aedf06e8db665bb7898112e90d17e48cc3f3289784a2b9ccf6e56fabc" ], - "version": "==5.1.5" + "version": "==6.0.0" }, "ucloud-common": { "editable": true, @@ -627,6 +761,13 @@ ], "version": "==1.6.0" }, + "pygments": { + "hashes": [ + "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", + "sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297" + ], + "version": "==2.4.2" + }, "pylint": { "hashes": [ "sha256:5d77031694a5fb97ea95e828c8d10fc770a1df6eb3906067aaed42201a8a6a09", @@ -662,10 +803,10 @@ }, "pyroma": { "hashes": [ - "sha256:54d332f540d4828bc5672b75ccf9e12d4b2f72a42a4f304bcec1c73565aecc26", - "sha256:6b94feb609e1896579302f0836ef2fad3f17e0557e3ddcd0d76206cd3e366d27" + "sha256:351758a81e2a12c970deb73687e239636aad52795cd81429695073d59fff0699", + "sha256:c49c00377219626bf83df42adf018cc231e6162b68cc7aaf2ff1c63803924102" ], - "version": "==2.5" + "version": "==2.6" }, "pyyaml": { "hashes": [ @@ -707,10 +848,10 @@ }, "six": { "hashes": [ - "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", - "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", + "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" ], - "version": "==1.12.0" + "version": "==1.13.0" }, "snowballstemmer": { "hashes": [ diff --git a/api/common_fields.py b/api/common_fields.py index 390fe40..c2152c9 100755 --- a/api/common_fields.py +++ b/api/common_fields.py @@ -4,6 +4,10 @@ from config import etcd_client as client from config import VM_PREFIX +class Optional: + pass + + class Field: def __init__(self, _name, _type, _value=None): self.name = _name @@ -18,7 +22,9 @@ class Field: if self.value == KeyError: self.add_error("'{}' field is a required field".format(self.name)) else: - if not isinstance(self.value, self.type): + if isinstance(self.value, Optional): + pass + elif not isinstance(self.value, self.type): self.add_error("Incorrect Type for '{}' field".format(self.name)) else: self.validation() diff --git a/api/config.py b/api/config.py index 4ae1b66..b9e7b82 100644 --- a/api/config.py +++ b/api/config.py @@ -24,6 +24,7 @@ REQUEST_PREFIX = config("REQUEST_PREFIX") FILE_PREFIX = config("FILE_PREFIX") IMAGE_PREFIX = config("IMAGE_PREFIX") IMAGE_STORE_PREFIX = config("IMAGE_STORE_PREFIX") +NETWORK_PREFIX = config("NETWORK_PREFIX") etcd_client = Etcd3Wrapper(host=config("ETCD_URL")) diff --git a/api/helper.py b/api/helper.py index 06b45b1..705800a 100755 --- a/api/helper.py +++ b/api/helper.py @@ -92,9 +92,11 @@ def resolve_image_name(name, etcd_client): return image_uuid + def random_bytes(num=6): return [random.randrange(256) for _ in range(num)] + def generate_mac(uaa=False, multicast=False, oui=None, separator=':', byte_fmt='%02x'): mac = random_bytes() if oui: @@ -112,6 +114,7 @@ def generate_mac(uaa=False, multicast=False, oui=None, separator=':', byte_fmt=' mac[0] |= 1 << 1 # set bit 1 return separator.join(byte_fmt % b for b in mac) + def get_ip_addr(mac_address, device): """Return IP address of a device provided its mac address / link local address and the device with which it is connected. @@ -140,3 +143,23 @@ def get_ip_addr(mac_address, device): if ip.is_global and mac_address == mac: result.append(ip) return result + + +def increment_etcd_counter(etcd_client, key): + kv = etcd_client.get(key) + + if kv: + counter = int(kv.value) + counter = counter + 1 + else: + counter = 1 + + etcd_client.put(key, str(counter)) + return counter + + +def get_etcd_counter(etcd_client, key): + kv = etcd_client.get(key) + if kv: + return int(kv.value) + return None diff --git a/api/main.py b/api/main.py index 1b25802..5096ebf 100644 --- a/api/main.py +++ b/api/main.py @@ -12,7 +12,7 @@ from flask_restful import Resource, Api from ucloud_common.vm import VMStatus from ucloud_common.request import RequestEntry, RequestType -from helper import generate_mac, get_ip_addr +from helper import generate_mac, get_ip_addr, get_etcd_counter, increment_etcd_counter from config import ( etcd_client, @@ -21,6 +21,7 @@ from config import ( HOST_PREFIX, FILE_PREFIX, IMAGE_PREFIX, + NETWORK_PREFIX, logging, REQUEST_POOL, VM_POOL, @@ -35,7 +36,6 @@ class CreateVM(Resource): @staticmethod def post(): data = request.json - print(data) validator = schemas.CreateVMSchema(data) if validator.is_valid(): vm_uuid = uuid4().hex @@ -57,10 +57,10 @@ class CreateVM(Resource): "image_uuid": validator.image_uuid, "log": [], "vnc_socket": "", - "mac": str(generate_mac()), + "network": data["network"], "metadata": { "ssh-keys": [] - } + }, } etcd_client.put(vm_key, vm_entry, value_in_json=True) @@ -80,9 +80,8 @@ class VmStatus(Resource): if validator.is_valid(): vm = VM_POOL.get(os.path.join(VM_PREFIX, data["uuid"])) vm_value = vm.value.copy() - vm_value["ip"] = list(map(str, get_ip_addr(vm.mac, "br0"))) + # vm_value["ip"] = list(map(str, get_ip_addr(vm.mac, "br0"))) vm.value = vm_value - print(vm.value) return vm.value else: return validator.get_errors(), 400 @@ -217,7 +216,7 @@ class ListUserVM(Resource): "specs": vm.value["specs"], "status": vm.value["status"], "hostname": vm.value["hostname"], - "mac": vm.value["mac"], + # "mac": vm.value["mac"], "vnc_socket": None if vm.value.get("vnc_socket", None) is None else vm.value["vnc_socket"], @@ -357,6 +356,44 @@ class RemoveSSHKey(Resource): else: return validator.get_errors(), 400 + +class CreateNetwork(Resource): + @staticmethod + def post(): + data = request.json + validator = schemas.CreateNetwork(data) + + if validator.is_valid(): + + network_entry = { + "id": increment_etcd_counter(etcd_client, "/v1/counter/vxlan"), + "type": data["type"] + } + network_key = os.path.join(NETWORK_PREFIX, data["name"], data["network_name"]) + etcd_client.put(network_key, network_entry, value_in_json=True) + return {"message": "Network successfully added."} + else: + return validator.get_errors(), 400 + + +class ListUserNetwork(Resource): + @staticmethod + def get(): + data = request.json + validator = schemas.OTPSchema(data) + + if validator.is_valid(): + prefix = os.path.join(NETWORK_PREFIX, data["name"]) + networks = etcd_client.get_prefix(prefix, value_in_json=True) + user_networks = [] + for net in networks: + net.value["name"] = net.key.split("/")[-1] + user_networks.append(net.value) + return {"networks": user_networks}, 200 + else: + return validator.get_errors(), 400 + + api.add_resource(CreateVM, "/vm/create") api.add_resource(VmStatus, "/vm/status") @@ -368,6 +405,7 @@ api.add_resource(ListPublicImages, "/image/list-public") api.add_resource(ListUserVM, "/user/vms") api.add_resource(ListUserFiles, "/user/files") +api.add_resource(ListUserNetwork, "/user/networks") api.add_resource(AddSSHKey, "/user/add-ssh") api.add_resource(RemoveSSHKey, "/user/remove-ssh") @@ -376,5 +414,7 @@ api.add_resource(GetSSHKeys, "/user/get-ssh") api.add_resource(CreateHost, "/host/create") api.add_resource(ListHost, "/host/list") +api.add_resource(CreateNetwork, "/network/create") + if __name__ == "__main__": app.run(host="::", debug=True) diff --git a/api/schemas.py b/api/schemas.py index b0e49b7..8aab841 100755 --- a/api/schemas.py +++ b/api/schemas.py @@ -23,11 +23,11 @@ import helper from ucloud_common.host import HostPool, HostStatus from ucloud_common.vm import VmPool, VMStatus -from common_fields import Field, VmUUIDField +from common_fields import Field, VmUUIDField, Optional from helper import check_otp, resolve_vm_name from config import etcd_client as client from config import (HOST_PREFIX, VM_PREFIX, IMAGE_PREFIX, - FILE_PREFIX, IMAGE_STORE_PREFIX) + FILE_PREFIX, IMAGE_STORE_PREFIX, NETWORK_PREFIX) HOST_POOL = HostPool(client, HOST_PREFIX) VM_POOL = VmPool(client, VM_PREFIX) @@ -85,7 +85,6 @@ class OTPSchema(BaseSchema): super().__init__(data=data, fields=_fields) def validation(self): - print(self.name.value, self.realm.value, self.token.value) if check_otp(self.name.value, self.realm.value, self.token.value) != 200: self.add_error("Wrong Credentials") @@ -206,20 +205,24 @@ class CreateHostSchema(OTPSchema): class CreateVMSchema(OTPSchema): def __init__(self, data): self.parsed_specs = {} + # Fields self.specs = Field("specs", dict, data.get("specs", KeyError)) self.vm_name = Field("vm_name", str, data.get("vm_name", KeyError)) self.image = Field("image", str, data.get("image", KeyError)) + self.network = Field("network", list, data.get("network", KeyError)) # Validation self.image.validation = self.image_validation self.vm_name.validation = self.vm_name_validation self.specs.validation = self.specs_validation + self.network.validation = self.network_validation - fields = [self.vm_name, self.image, self.specs] + fields = [self.vm_name, self.image, self.specs, self.network] super().__init__(data=data, fields=fields) + def image_validation(self): try: image_uuid = helper.resolve_image_name(self.image.value, client) @@ -234,6 +237,18 @@ class CreateVMSchema(OTPSchema): 'VM with same name "{}" already exists'.format(self.vm_name.value) ) + def network_validation(self): + _network = self.network.value + + if _network: + for net in _network: + network = client.get(os.path.join(NETWORK_PREFIX, + self.name.value, + net), value_in_json=True) + if not network: + self.add_error("Network with name {} does not exists"\ + .format(net)) + def specs_validation(self): ALLOWED_BASE = 10 @@ -416,4 +431,30 @@ class GetSSHSchema(OTPSchema): self.key_name = Field("key_name", str, data.get("key_name", None)) fields = [self.key_name] - super().__init__(data=data, fields=fields) \ No newline at end of file + super().__init__(data=data, fields=fields) + + +class CreateNetwork(OTPSchema): + def __init__(self, data): + self.network_name = Field("network_name", str, data.get("network_name", KeyError)) + self.type = Field("type", str, data.get("type", KeyError)) + + self.network_name.validation = self.network_name_validation + self.type.validation = self.network_type_validation + + fields = [self.network_name, self.type] + super().__init__(data, fields=fields) + + def network_name_validation(self): + network = client.get(os.path.join(NETWORK_PREFIX, + self.name.value, + self.network_name.value), + value_in_json=True) + if network: + self.add_error("Network with name {} already exists"\ + .format(self.network_name.value)) + + def network_type_validation(self): + supported_network_types = ["vxlan"] + if self.type.value not in supported_network_types: + self.add_error("Unsupported Network Type. Supported network types are {}".format(supported_network_types)) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..6247f7e --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..197cfce --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,52 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +project = 'ucloud' +copyright = '2019, Ahmed Bilal Khalid' +author = 'Ahmed Bilal Khalid' + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'alabaster' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..72d77d3 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,21 @@ +.. ucloud documentation master file, created by + sphinx-quickstart on Mon Nov 11 19:08:16 2019. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to ucloud's documentation! +================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + introduction/introduction + introduction/installation + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/source/introduction/installation.rst b/docs/source/introduction/installation.rst new file mode 100644 index 0000000..71269b6 --- /dev/null +++ b/docs/source/introduction/installation.rst @@ -0,0 +1,200 @@ +Installation +============ + +.. note:: + The below installation instructions are for single node and without ceph ucloud installation. + + The instructions assumes the following things + + * User is **root**. + * Base Directory is `/root/`. + +Alpine +------ +Python Wheel (Binary) Packages does not support Alpine Linux as it is using musl libc instead of glibc. +Therefore, expect longer installation times than other linux distributions. + +Enable Edge Repos, Update and Upgrade +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. warning:: + The below commands would overwrite your repositories sources and upgrade all packages and their dependencies to match those available in edge repos. **So, be warned** +.. code-block:: sh + :linenos: + + cat > /etc/apk/repositories << EOF + http://dl-cdn.alpinelinux.org/alpine/edge/main + http://dl-cdn.alpinelinux.org/alpine/edge/community + http://dl-cdn.alpinelinux.org/alpine/edge/testing + EOF + + apk update + apk upgrade + + +Install Dependencies +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: sh + :linenos: + + apk add git python3 alpine-sdk python3-dev etcd etcd-ctl openntpd \ + libffi-dev openssl-dev make py3-protobuf py3-tempita chrony \ + qemu qemu-system-x86_64 + + pip3 install pipenv + +Syncronize Date/Time +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: sh + :linenos: + + service chronyd start + rc-update add chronyd + + +Start etcd and enable it +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: sh + :linenos: + + start-stop-daemon -b etcd + rc-update add etcd + + +Install uotp +~~~~~~~~~~~~ + +.. code-block:: sh + :linenos: + + git clone https://code.ungleich.ch/ungleich-public/uotp.git + cd uotp + mv .env.sample .env + + pipenv --three --site-packages + pipenv install + pipenv run python app.py + +Run :code:`ETCDCTL_API=3 etcdctl get /uotp/admin --print-value-only` to get admin seed. A sample output + +.. code-block:: json + + { + "seed": "FYTVQ72A2CJJ4TB4", + "realm": ["ungleich-admin"] + } + +Now, run :code:`pipenv run python scripts/create-auth.py FYTVQ72A2CJJ4TB4` (Replace **FYTVQ72A2CJJ4TB4** with your admin seed obtained in previous step). +A sample output is as below. It shows seed of auth. + +.. code-block:: json + + { + "message": "Account Created\nname: auth, realm: ['ungleich-auth'], seed: XZLTUMX26TRAZOXC" + } + +.. note:: + Please note both **admin** and **auth** seeds as we would need them in setting up ucloud + + +Install and configure ucloud +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: sh + :linenos: + + git clone https://code.ungleich.ch/ucloud/ucloud.git + cd ucloud + + pipenv --three --site-packages + pipenv install + + +You just need to update **AUTH_SEED** in the below code to match your auth's seed. + + +.. code-block:: sh + :linenos: + + cat > .env << EOF + AUTH_NAME=auth + AUTH_SEED=XZLTUMX26TRAZOXC + AUTH_REALM=ungleich-auth + + REALM_ALLOWED = ["ungleich-admin", "ungleich-user"] + + OTP_SERVER="http://127.0.0.1:8000/" + + ETCD_URL=localhost + + WITHOUT_CEPH=True + + BASE_DIR=/var/www + IMAGE_DIR=/var/image + VM_DIR=/var/vm + + VM_PREFIX=/v1/vm/ + HOST_PREFIX=/v1/host/ + REQUEST_PREFIX=/v1/request/ + FILE_PREFIX=/v1/file/ + IMAGE_PREFIX=/v1/image/ + IMAGE_STORE_PREFIX=/v1/image_store/ + USER_PREFIX=/v1/user/ + NETWORK_PREFIX=/v1/network/ + + ssh_username=meow + ssh_pkey="~/.ssh/id_rsa" + + EOF + + +Install and configure ucloud-cli +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: sh + :linenos: + + git clone https://code.ungleich.ch/ucloud/ucloud-cli.git + cd ucloud-cli + pipenv --three --site-packages + pipenv install + + cat > .env << EOF + UCLOUD_API_SERVER=http://localhost:5000 + EOF + + +Environment Variables and aliases +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To ease usage of ucloud and its various componenets put the following in your shell +profile e.g *~/.profile* + +.. code-block:: sh + + export OTP_NAME=admin + export OTP_REALM=ungleich-admin + export OTP_SEED=FYTVQ72A2CJJ4TB4 + + alias ucloud='cd /root/ucloud/ && pipenv run python ucloud.py' + alias ucloud-cli='cd /root/ucloud-cli/ && pipenv run python ucloud.py' + alias uotp='cd /root/uotp/ && pipenv run python app.py' + +and run :code:`source ~/.profile` + + +Running ucloud +~~~~~~~~~~~~~~ + +.. code-block:: sh + + ucloud api + +We need to create a host by executing the following command + +.. code-block:: sh + + \ No newline at end of file diff --git a/docs/source/introduction/introduction.rst b/docs/source/introduction/introduction.rst new file mode 100644 index 0000000..022713e --- /dev/null +++ b/docs/source/introduction/introduction.rst @@ -0,0 +1,23 @@ +Introduction +============ + +**Open** + **Simple** + **Easy to hack** + **IPv6 First** + +Tech Stack +---------- +* Python 3 as main language. +* Flask for APIs. +* JSON for specifications. +* QEMU (+ kvm acceleration) as hypervisor. +* etcd for key/value storage (specifically all metadata e.g Virtual Machine Specifications, Networks Specifications, Images Specifications etc.). +* Ceph for image storage. + +Components +---------- +* API +* Scheduler +* Host +* File Scanner +* Image Scanner +* Metadata Server +* VM Init Scripts (dubbed as ucloud-init) \ No newline at end of file diff --git a/host/config.py b/host/config.py index 03cee29..c2dbb06 100755 --- a/host/config.py +++ b/host/config.py @@ -22,6 +22,7 @@ etcd_wrapper_kwargs = {"host": config("ETCD_URL")} etcd_client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) HOST_PREFIX = config("HOST_PREFIX") +NETWORK_PREFIX = config("NETWORK_PREFIX") VM_PREFIX = config("VM_PREFIX") REQUEST_PREFIX = config("REQUEST_PREFIX") VM_DIR = config("VM_DIR") diff --git a/host/main.py b/host/main.py index b86e88e..8a81e86 100755 --- a/host/main.py +++ b/host/main.py @@ -1,5 +1,4 @@ import argparse -# import threading import time import os import sys diff --git a/host/virtualmachine.py b/host/virtualmachine.py index f99ffd0..9e01094 100755 --- a/host/virtualmachine.py +++ b/host/virtualmachine.py @@ -6,22 +6,23 @@ import errno import os -import subprocess +import subprocess as sp import tempfile import time +import random from functools import wraps from os.path import join from typing import Union +from decouple import config import bitmath import sshtunnel -from decouple import config import qmp from config import (WITHOUT_CEPH, VM_PREFIX, VM_DIR, IMAGE_DIR, - etcd_client, logging, request_pool, - running_vms, vm_pool) + NETWORK_PREFIX, etcd_client, logging, + request_pool, running_vms, vm_pool) from ucloud_common.helpers import get_ipv4_address from ucloud_common.request import RequestEntry, RequestType from ucloud_common.vm import VMEntry, VMStatus @@ -37,13 +38,62 @@ class VM: return "VM({})".format(self.key) +def create_dev(script, _id, dev): + assert isinstance(_id, str) and isinstance(dev, str), "_id and dev both must be string" + try: + output = sp.check_output([script, _id, dev], stderr=sp.PIPE) + except Exception as e: + print(e.stderr) + return None + else: + return output.decode("utf-8").strip() + + +def create_vxlan_br_tap(_id, _dev): + network_script_base = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'network') + vxlan = create_dev(script=os.path.join(network_script_base, 'create-vxlan.sh'), + _id=_id, dev=_dev) + if vxlan: + bridge = create_dev(script=os.path.join(network_script_base, 'create-bridge.sh'), + _id=_id, dev=vxlan) + if bridge: + tap = create_dev(script=os.path.join(network_script_base, 'create-tap.sh'), + _id=str(random.randint(1, 100000)), dev=bridge) + if tap: + return tap + + +def random_bytes(num=6): + return [random.randrange(256) for _ in range(num)] + + +def generate_mac(uaa=False, multicast=False, oui=None, separator=':', byte_fmt='%02x'): + mac = random_bytes() + if oui: + if type(oui) == str: + oui = [int(chunk) for chunk in oui.split(separator)] + mac = oui + random_bytes(num=6-len(oui)) + else: + if multicast: + mac[0] |= 1 # set bit 0 + else: + mac[0] &= ~1 # clear bit 0 + if uaa: + mac[0] &= ~(1 << 1) # clear bit 1 + else: + mac[0] |= 1 << 1 # set bit 1 + return separator.join(byte_fmt % b for b in mac) + + def get_start_command_args( - vm_entry, vnc_sock_filename: str, migration=False, migration_port=4444 + vm_entry, vnc_sock_filename: str, migration=False, migration_port=4444, ): threads_per_core = 1 vm_memory = int(bitmath.parse_string(vm_entry.specs["ram"]).to_MB()) vm_cpus = int(vm_entry.specs["cpu"]) vm_uuid = vm_entry.uuid + vm_networks = vm_entry.network + if WITHOUT_CEPH: command = "-drive file={},format=raw,if=virtio,cache=none".format( @@ -62,8 +112,21 @@ def get_start_command_args( if migration: command += " -incoming tcp:0:{}".format(migration_port) + + tap = None + for network_name in vm_networks: + _key = os.path.join(NETWORK_PREFIX, vm_entry.owner, network_name) + network = etcd_client.get(_key, value_in_json=True) + network_type = network.value["type"] + network_id = str(network.value["id"]) + + if network_type == "vxlan": + tap = create_vxlan_br_tap(network_id, "eno1") + + command += " -netdev tap,id=vmnet{net_id},ifname={tap},script=no,downscript=no"\ + " -device virtio-net-pci,netdev=vmnet{net_id},mac={mac}"\ + .format(tap=tap, net_id=network_id, mac=generate_mac()) - command += " -nic tap,model=virtio,mac={}".format(vm_entry.mac) return command.split(" ") @@ -144,8 +207,8 @@ def create(vm_entry: VMEntry): ] try: - subprocess.check_output(_command_to_create) - except subprocess.CalledProcessError as e: + sp.check_output(_command_to_create) + except sp.CalledProcessError as e: if e.returncode == errno.EEXIST: logging.debug("Image for vm %s exists", vm_entry.uuid) # File Already exists. No Problem Continue @@ -158,7 +221,7 @@ def create(vm_entry: VMEntry): vm_entry.status = "ERROR" else: try: - subprocess.check_output(_command_to_extend) + sp.check_output(_command_to_extend) except Exception as e: logging.exception(e) else: @@ -199,7 +262,7 @@ def delete(vm_entry): vm_deletion_command = ["rbd", "rm", path_without_protocol] try: - subprocess.check_output(vm_deletion_command) + sp.check_output(vm_deletion_command) except Exception as e: logging.exception(e) else: diff --git a/network/create-bridge.sh b/network/create-bridge.sh new file mode 100755 index 0000000..78ebbee --- /dev/null +++ b/network/create-bridge.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +if [ $# -ne 2 ]; then + echo "$0 brid dev" + echo "f.g. $0 100 vxlan100" + echo "Missing arguments" >&2 + exit 1 +fi + +brid=$1; shift +dev=$1; shift +bridge=br${brid} + +sysctl net.ipv6.conf.all.forwarding=1 > /dev/null + +if ! ip link show $bridge > /dev/null 2> /dev/null; then + ip link add name $bridge type bridge + ip link set $bridge up + ip link set $dev master $bridge + ip address add fd00:/64 dev $bridge +fi + +echo $bridge \ No newline at end of file diff --git a/network/create-tap.sh b/network/create-tap.sh new file mode 100755 index 0000000..4a5e470 --- /dev/null +++ b/network/create-tap.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +if [ $# -ne 2 ]; then + echo "$0 tapid dev" + echo "f.g. $0 100 br100" + echo "Missing arguments" >&2 + exit 1 +fi + +tapid=$1; shift +bridge=$1; shift +vxlan=vxlan${tapid} +tap=tap${tapid} + +if ! ip link show $tap > /dev/null 2> /dev/null; then + ip tuntap add $tap mode tap user `whoami` + ip link set $tap up + sleep 0.5s + ip link set $tap master $bridge +fi + +echo $tap \ No newline at end of file diff --git a/network/create-vxlan-on-dev.sh b/network/create-vxlan-on-dev.sh deleted file mode 100644 index b366392..0000000 --- a/network/create-vxlan-on-dev.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh - -if [ $# -ne 2 ]; then - echo "$0 vxlanid dev" - echo "f.i. $0 100 eth0" - exit 1 -fi - -netid=$1; shift -dev=$1; shift - -ip -6 link add vxlan${netid} type vxlan \ - id ${netid} \ - dstport 4789 \ - group ff05::${netid} \ - dev ${dev} \ - ttl 5 - -ip link set ${dev} up diff --git a/network/create-vxlan.sh b/network/create-vxlan.sh new file mode 100755 index 0000000..1a730f6 --- /dev/null +++ b/network/create-vxlan.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +if [ $# -ne 2 ]; then + echo "$0 vxlanid dev" + echo "f.i. $0 100 eno1" + echo "Missing arguments" >&2 + exit 1 +fi + +netid=$1; shift +dev=$1; shift +vxlan=vxlan${netid} + +if ! ip link show $vxlan > /dev/null 2> /dev/null; then + ip -6 link add $vxlan type vxlan \ + id $netid \ + dstport 4789 \ + group ff05::$netid \ + dev $dev \ + ttl 5 + + ip link set $dev up + ip link set $vxlan up +fi + +echo $vxlan \ No newline at end of file diff --git a/scheduler/helper.py b/scheduler/helper.py index 65bd12d..577bc91 100755 --- a/scheduler/helper.py +++ b/scheduler/helper.py @@ -15,7 +15,7 @@ host_pool = HostPool(client, config("HOST_PREFIX")) request_pool = RequestPool(client, config("REQUEST_PREFIX")) -def accumulated_specs(vms_specs): +def accumulated_specs(vms_specs): if not vms_specs: return {} return reduce((lambda x, y: Counter(x) + Counter(y)), vms_specs) @@ -41,7 +41,6 @@ def remaining_resources(host_specs, vms_specs): _remaining[component] = map(lambda x: int(bitmath.parse_string(x).to_MB()), _remaining[component]) _remaining[component] = reduce(lambda x, y: x + y, _remaining[component], 0) - print(_vms_specs, _remaining) _remaining.subtract(_vms_specs) return _remaining @@ -65,16 +64,12 @@ def get_suitable_host(vm_specs, hosts=None): running_vms_specs = [vm.specs for vm in vms] # Accumulate all of their combined specs - running_vms_accumulated_specs = accumulated_specs( - running_vms_specs - ) + running_vms_accumulated_specs = accumulated_specs(running_vms_specs) # Find out remaining resources after # host_specs - already running vm_specs - remaining = remaining_resources( - host.specs, running_vms_accumulated_specs - ) - + remaining = remaining_resources(host.specs, running_vms_accumulated_specs) + # Find out remaining - new_vm_specs remaining = remaining_resources(remaining, vm_specs) diff --git a/scheduler/main.py b/scheduler/main.py index 6e0ba90..3fb0d1b 100755 --- a/scheduler/main.py +++ b/scheduler/main.py @@ -54,6 +54,9 @@ def main(): elif request_entry.type == RequestType.ScheduleVM: vm_entry = vm_pool.get(request_entry.uuid) + if vm_entry is None: + logging.info("Trying to act on {} but it is deleted".format(request_entry.uuid)) + continue client.client.delete(request_entry.key) # consume Request # If the Request is about a VM which is labelled as "migration" From e37222c1c749036aec26462874b2507437395736 Mon Sep 17 00:00:00 2001 From: meow Date: Tue, 12 Nov 2019 11:50:41 +0500 Subject: [PATCH 008/284] ucloud-host can not be started by using hostname --- docs/source/index.rst | 1 + docs/source/introduction/installation.rst | 14 --------- docs/source/introduction/usage.rst | 35 +++++++++++++++++++++++ host/main.py | 8 +++--- 4 files changed, 40 insertions(+), 18 deletions(-) create mode 100644 docs/source/introduction/usage.rst diff --git a/docs/source/index.rst b/docs/source/index.rst index 72d77d3..28d7a53 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,6 +12,7 @@ Welcome to ucloud's documentation! introduction/introduction introduction/installation + introduction/usage Indices and tables ================== diff --git a/docs/source/introduction/installation.rst b/docs/source/introduction/installation.rst index 71269b6..f42ca3e 100644 --- a/docs/source/introduction/installation.rst +++ b/docs/source/introduction/installation.rst @@ -184,17 +184,3 @@ profile e.g *~/.profile* alias uotp='cd /root/uotp/ && pipenv run python app.py' and run :code:`source ~/.profile` - - -Running ucloud -~~~~~~~~~~~~~~ - -.. code-block:: sh - - ucloud api - -We need to create a host by executing the following command - -.. code-block:: sh - - \ No newline at end of file diff --git a/docs/source/introduction/usage.rst b/docs/source/introduction/usage.rst new file mode 100644 index 0000000..5cab865 --- /dev/null +++ b/docs/source/introduction/usage.rst @@ -0,0 +1,35 @@ +Usage +===== + +Start API +---------- + +.. code-block:: sh + + ucloud api + +Host Creation +------------- + +Currently, we don't have any host (that runs virtual machines). +So, we need to create it by executing the following command + +.. code-block:: sh + + ucloud-cli host create --hostname ungleich.ch --cpu 32 --ram '32GB' --os-ssd '32GB' + +You should see something like the following + +.. code-block:: json + + { + "message": "Host Created" + } + +Start Scheduler +--------------- + +.. code-block:: sh + + ucloud scheduler + diff --git a/host/main.py b/host/main.py index 8a81e86..9d9f396 100755 --- a/host/main.py +++ b/host/main.py @@ -19,8 +19,8 @@ import etcd3 def update_heartbeat(host): client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) host_pool = HostPool(client, HOST_PREFIX) - this_host = host_pool.get(host) - + this_host = next(filter(lambda h: h.hostname == host, host_pool.hosts), None) + while True: this_host.update_heartbeat() host_pool.put(this_host) @@ -78,8 +78,8 @@ def main(): heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(args.hostname,)) host_pool = HostPool(etcd_client, HOST_PREFIX) - host = host_pool.get(args.hostname) - assert host, "No such host" + host = next(filter(lambda h: h.hostname == args.hostname, host_pool.hosts), None) + assert host is not None, "No such host" try: heartbeat_updating_process.start() From 5d613df33dcf6ae24985e645849460193b5af6b9 Mon Sep 17 00:00:00 2001 From: meow Date: Tue, 12 Nov 2019 15:26:10 +0500 Subject: [PATCH 009/284] Image Creation Message Corrected + ucloud-host read Physical Device for VXLAN from .env --- api/main.py | 2 +- docs/source/introduction/installation.rst | 8 +- docs/source/introduction/introduction.rst | 7 + docs/source/introduction/usage.rst | 167 ++++++++++++++++++++++ host/virtualmachine.py | 2 +- 5 files changed, 183 insertions(+), 3 deletions(-) diff --git a/api/main.py b/api/main.py index 5096ebf..27b475d 100644 --- a/api/main.py +++ b/api/main.py @@ -108,7 +108,7 @@ class CreateImage(Resource): os.path.join(IMAGE_PREFIX, data["uuid"]), json.dumps(image_entry_json) ) - return {"message": "Image successfully created"} + return {"message": "Image queued for creation."} return validator.get_errors(), 400 diff --git a/docs/source/introduction/installation.rst b/docs/source/introduction/installation.rst index f42ca3e..05ab656 100644 --- a/docs/source/introduction/installation.rst +++ b/docs/source/introduction/installation.rst @@ -40,7 +40,7 @@ Install Dependencies apk add git python3 alpine-sdk python3-dev etcd etcd-ctl openntpd \ libffi-dev openssl-dev make py3-protobuf py3-tempita chrony \ - qemu qemu-system-x86_64 + qemu qemu-system-x86_64 qemu-img pip3 install pipenv @@ -148,6 +148,8 @@ You just need to update **AUTH_SEED** in the below code to match your auth's see ssh_username=meow ssh_pkey="~/.ssh/id_rsa" + VXLAN_PHY_DEV="eth0" + EOF @@ -166,6 +168,10 @@ Install and configure ucloud-cli UCLOUD_API_SERVER=http://localhost:5000 EOF + mkdir /var/www/ + mkdir /var/image/ + mkdir /var/vm/ + Environment Variables and aliases ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/introduction/introduction.rst b/docs/source/introduction/introduction.rst index 022713e..14d08d8 100644 --- a/docs/source/introduction/introduction.rst +++ b/docs/source/introduction/introduction.rst @@ -3,6 +3,13 @@ Introduction **Open** + **Simple** + **Easy to hack** + **IPv6 First** +ucloud is an easy to use cloud management system. + + +It is an alternative to OpenStack, OpenNebula or Cloudstack. + +ucloud is the first cloud management system that puts IPv6 first. ucloud also has an integral ordering process that we missed in existing solutions. + Tech Stack ---------- * Python 3 as main language. diff --git a/docs/source/introduction/usage.rst b/docs/source/introduction/usage.rst index 5cab865..2140cd1 100644 --- a/docs/source/introduction/usage.rst +++ b/docs/source/introduction/usage.rst @@ -28,8 +28,175 @@ You should see something like the following Start Scheduler --------------- +Scheduler is responsible for scheduling VMs on appropriate host. .. code-block:: sh ucloud scheduler +Start Host +---------- +Host is responsible for handling the following actions + +* Start VM. +* Stop VM. +* Create VM. +* Delete VM. +* Migrate VM. +* Manage Network Resources needed by VMs. + +It uses a hypervisor such as QEMU to perform these actions. + +To start host we created earlier, execute the following command + +.. code-block:: sh + + ucloud host ungleich.ch + +Create OS Image +--------------- +First, we need to upload the file. + +.. code-block:: sh + + mkdir /var/www/admin + (cd /var/www/admin && wget http://[2a0a:e5c0:2:12:0:f0ff:fea9:c3d9]/alpine-untouched.qcow2) + +Run File Scanner and Image Scanner +------------------------------------ +Currently, our uploaded file *alpine-untouched.qcow2* is not tracked by ucloud. We can only make +images from tracked files. So, we need to track the file by running File Scanner + +.. code-block:: sh + + ucloud filescanner + +File Scanner would run, scan your uploaded image and track it. You can check whether your image +is successfully tracked by executing the :code:`ucloud-cli user files`, It will return something like the following + +.. _list-user-files: + +.. code-block:: json + + { + "message": [ + { + "filename": "alpine-untouched.qcow2", + "uuid": "3f75bd20-45d6-4013-89c4-7fceaedc8dda" + } + ] + } + +Our file is now being tracked by ucloud. Lets create an OS image using the uploaded file. + +An image belongs to an image store. There are two types of store + +* Public Image Store +* Private Image Store (Not Implemented Yet) + +.. note:: + **Quick Quiz** Have we create an image store yet? + +The answer is **No, we haven't**. Creating an example image store is very easy. +Just execute the following command + +.. code-block:: sh + + pipenv run python ~/ucloud/api/create_image_store.py + +An image store (with name = "images") would be created. Now, we are fully ready for creating our +very own image. Executing the following command to create image using the file uploaded earlier + +.. code-block:: sh + + ucloud-cli image create-from-file --name alpine --uuid 3f75bd20-45d6-4013-89c4-7fceaedc8dda --image-store-name images + +Please note that your **uuid** would be different. See :ref:`List of user files `. + +Now, ucloud have received our request to create an image from file. We have to run Image Scanner to make the image. + +.. code-block:: sh + + ucloud imagescanner + +To make sure, that our image is create run :code:`ucloud-cli image list --public`. You would get +output something like the following + +.. code-block:: json + + { + "images": [ + { + "name": "images:alpine", + "status": "CREATED" + } + ] + } + +Create VM +--------- + +The following command would create a Virtual Machine (name: meow) with following specs + +* CPU: 1 +* RAM: 1GB +* OS-SSD: 4GB +* OS: Alpine Linux + +.. code-block:: sh + + ucloud-cli vm create --vm-name meow --cpu 1 --ram '1gb' --os-ssd '4gb' --image images:alpine + +Check VM Status +--------------- + +.. code-block:: sh + + ucloud-cli vm status --vm-name meow + + +.. code-block:: json + + { + "hostname": "/v1/host/74c21c332f664972bf5078e8de080eea", + "image_uuid": "3f75bd20-45d6-4013-89c4-7fceaedc8dda", + "in_migration": null, + "log": [ + "2019-11-12T09:11:09.800798 - Started successfully" + ], + "metadata": { + "ssh-keys": [] + }, + "name": "meow", + "network": [], + "owner": "admin", + "owner_realm": "ungleich-admin", + "specs": { + "cpu": 1, + "hdd": [], + "os-ssd": "4.0 GB", + "ram": "1.0 GB" + }, + "status": "RUNNING", + "vnc_socket": "/tmp/tmpj1k6sdo_" + } + +Create Network +-------------- + +.. code-block:: sh + + ucloud-cli network create --network-name mynet --network-type vxlan + + +.. code-block:: json + + { + "message": "Network successfully added." + } + +Create VM using this network + +.. code-block:: sh + + ucloud-cli vm create --vm-name meow2 --cpu 1 --ram '1gb' --os-ssd '4gb' --image images:alpine --network mynet diff --git a/host/virtualmachine.py b/host/virtualmachine.py index 9e01094..ef9c2cc 100755 --- a/host/virtualmachine.py +++ b/host/virtualmachine.py @@ -121,7 +121,7 @@ def get_start_command_args( network_id = str(network.value["id"]) if network_type == "vxlan": - tap = create_vxlan_br_tap(network_id, "eno1") + tap = create_vxlan_br_tap(network_id, config("VXLAN_PHY_DEV")) command += " -netdev tap,id=vmnet{net_id},ifname={tap},script=no,downscript=no"\ " -device virtio-net-pci,netdev=vmnet{net_id},mac={mac}"\ From f6eb2ec01fa8730286af81a2aa87a2dc23a959c0 Mon Sep 17 00:00:00 2001 From: meow Date: Tue, 12 Nov 2019 16:33:20 +0500 Subject: [PATCH 010/284] Remove warning of vxlan not working | Docs Updated --- docs/Makefile | 17 +++++------ docs/make.bat | 35 ----------------------- docs/source/introduction/installation.rst | 2 ++ docs/source/introduction/usage.rst | 2 ++ 4 files changed, 11 insertions(+), 45 deletions(-) delete mode 100644 docs/make.bat diff --git a/docs/Makefile b/docs/Makefile index d0c3cbf..734e5ff 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -5,16 +5,13 @@ # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build -SOURCEDIR = source -BUILDDIR = build +SOURCEDIR = source/ +BUILDDIR = build/ +DESTINATION=root@[2a0a:e5c0:2:12:0:f0ff:fea9:c3d9]:/home/app/static/ucloud -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) +publish: build + rsync -av $(BUILDDIR)/ $(DESTINATION) -.PHONY: help Makefile +build: + $(SPHINXBUILD) "$(SOURCEDIR)" "$(BUILDDIR)" -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 6247f7e..0000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=source -set BUILDDIR=build - -if "%1" == "" goto help - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd diff --git a/docs/source/introduction/installation.rst b/docs/source/introduction/installation.rst index 05ab656..4a67e04 100644 --- a/docs/source/introduction/installation.rst +++ b/docs/source/introduction/installation.rst @@ -31,6 +31,8 @@ Enable Edge Repos, Update and Upgrade apk update apk upgrade + reboot + Install Dependencies ~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/introduction/usage.rst b/docs/source/introduction/usage.rst index 2140cd1..325d868 100644 --- a/docs/source/introduction/usage.rst +++ b/docs/source/introduction/usage.rst @@ -200,3 +200,5 @@ Create VM using this network .. code-block:: sh ucloud-cli vm create --vm-name meow2 --cpu 1 --ram '1gb' --os-ssd '4gb' --image images:alpine --network mynet + + From fefbe2e1c7bec42a73b8b89ce855758b0ca64f1d Mon Sep 17 00:00:00 2001 From: meow Date: Fri, 15 Nov 2019 21:11:45 +0500 Subject: [PATCH 011/284] More Networking Implementation --- Pipfile | 1 + Pipfile.lock | 21 +++-- TODO.md | 3 +- api/helper.py | 18 ++++ api/main.py | 43 +++++++-- api/schemas.py | 3 +- docs/Makefile | 6 +- docs/source/conf.py | 5 +- docs/source/index.rst | 4 +- docs/source/introduction/installation.rst | 2 +- docs/source/introduction/introduction.rst | 5 +- .../usage.rst => usage/usage-for-admins.rst} | 80 ++--------------- docs/source/usage/usage-for-users.rst | 89 +++++++++++++++++++ host/virtualmachine.py | 58 +++++++++--- metadata/main.py | 2 +- network/create-bridge.sh | 9 +- network/radvd-template.conf | 13 +++ 17 files changed, 243 insertions(+), 119 deletions(-) rename docs/source/{introduction/usage.rst => usage/usage-for-admins.rst} (65%) create mode 100644 docs/source/usage/usage-for-users.rst create mode 100644 network/radvd-template.conf diff --git a/Pipfile b/Pipfile index 5aba57b..22120cd 100644 --- a/Pipfile +++ b/Pipfile @@ -19,6 +19,7 @@ pyotp = "*" sshtunnel = "*" helper = "*" sphinx = "*" +pynetbox = "*" [requires] python_version = "3.5" diff --git a/Pipfile.lock b/Pipfile.lock index 6167f76..fa02525 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "45db72f1a666be82e7dc044ced7e7ad7a5b5a6efbb8b8103e6ad04c93a7d017a" + "sha256": "5e4aa65086afdf9ac2f1479e9e35684f767dfbbd13877c4e4a23dd471aef6c13" }, "pipfile-spec": 6, "requires": { @@ -377,6 +377,13 @@ ], "version": "==1.3.0" }, + "pynetbox": { + "hashes": [ + "sha256:e171380b36bedb7e0cd6a735fe8193d5809b373897b6905a2de43342761426c7" + ], + "index": "pypi", + "version": "==4.0.8" + }, "pyotp": { "hashes": [ "sha256:c88f37fd47541a580b744b42136f387cdad481b560ef410c0d85c957eb2a2bc0", @@ -522,10 +529,10 @@ }, "urllib3": { "hashes": [ - "sha256:3de946ffbed6e6746608990594d08faac602528ac7015ac28d33cee6a45b7398", - "sha256:9a107b99a5393caf59c7aa3c1249c16e6879447533d0887f4336dde834c7be86" + "sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293", + "sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745" ], - "version": "==1.25.6" + "version": "==1.25.7" }, "werkzeug": { "hashes": [ @@ -896,10 +903,10 @@ }, "urllib3": { "hashes": [ - "sha256:3de946ffbed6e6746608990594d08faac602528ac7015ac28d33cee6a45b7398", - "sha256:9a107b99a5393caf59c7aa3c1249c16e6879447533d0887f4336dde834c7be86" + "sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293", + "sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745" ], - "version": "==1.25.6" + "version": "==1.25.7" }, "vulture": { "hashes": [ diff --git a/TODO.md b/TODO.md index 20be658..c65196c 100644 --- a/TODO.md +++ b/TODO.md @@ -2,4 +2,5 @@ - Check for `etcd3.exceptions.ConnectionFailedError` when calling some etcd operation to avoid crashing whole application -- Throw KeyError instead of returning None when some key is not found in etcd \ No newline at end of file +- Throw KeyError instead of returning None when some key is not found in etcd +- Expose more details in ListUserFiles \ No newline at end of file diff --git a/api/helper.py b/api/helper.py index 705800a..5f27c22 100755 --- a/api/helper.py +++ b/api/helper.py @@ -163,3 +163,21 @@ def get_etcd_counter(etcd_client, key): if kv: return int(kv.value) return None + +def mac2ipv6(mac, prefix): + # only accept MACs separated by a colon + parts = mac.split(":") + + # modify parts to match IPv6 value + parts.insert(3, "ff") + parts.insert(4, "fe") + parts[0] = "%x" % (int(parts[0], 16) ^ 2) + + # format output + ipv6Parts = [str(0)]*4 + for i in range(0, len(parts), 2): + ipv6Parts.append("".join(parts[i:i+2])) + + lower_part = ipaddress.IPv6Address(":".join(ipv6Parts)) + prefix = ipaddress.IPv6Address(prefix) + return str(prefix + int(lower_part)) diff --git a/api/main.py b/api/main.py index 27b475d..a5258cc 100644 --- a/api/main.py +++ b/api/main.py @@ -1,6 +1,8 @@ import json import subprocess import os +import pynetbox +import decouple import schemas @@ -12,7 +14,8 @@ from flask_restful import Resource, Api from ucloud_common.vm import VMStatus from ucloud_common.request import RequestEntry, RequestType -from helper import generate_mac, get_ip_addr, get_etcd_counter, increment_etcd_counter +from helper import (generate_mac, get_ip_addr, get_etcd_counter, + increment_etcd_counter, mac2ipv6) from config import ( etcd_client, @@ -46,7 +49,7 @@ class CreateVM(Resource): 'os-ssd': validator.specs['os-ssd'], 'hdd': validator.specs['hdd'] } - + macs = [generate_mac() for i in range(len(data["network"]))] vm_entry = { "name": data["vm_name"], "owner": data["name"], @@ -57,7 +60,7 @@ class CreateVM(Resource): "image_uuid": validator.image_uuid, "log": [], "vnc_socket": "", - "network": data["network"], + "network": list(zip(data["network"], macs)), "metadata": { "ssh-keys": [] }, @@ -80,7 +83,13 @@ class VmStatus(Resource): if validator.is_valid(): vm = VM_POOL.get(os.path.join(VM_PREFIX, data["uuid"])) vm_value = vm.value.copy() - # vm_value["ip"] = list(map(str, get_ip_addr(vm.mac, "br0"))) + vm_value["ip"] = [] + for network_and_mac in vm.network: + network_name, mac = network_and_mac + network = etcd_client.get(os.path.join(NETWORK_PREFIX, data["name"], network_name), + value_in_json=True) + ipv6_addr = network.value.get("ipv6").split("::")[0] + "::" + vm_value["ip"].append(mac2ipv6(mac, ipv6_addr)) vm.value = vm_value return vm.value else: @@ -296,7 +305,8 @@ class GetSSHKeys(Resource): if not validator.key_name.value: # {user_prefix}/{realm}/{name}/key/ - etcd_key = os.path.join(USER_PREFIX, data["realm"], data["name"], "key") + etcd_key = os.path.join(decouple.config("USER_PREFIX"), data["realm"], + data["name"], "key") etcd_entry = etcd_client.get_prefix(etcd_key, value_in_json=True) keys = {key.key.split("/")[-1]: key.value for key in etcd_entry} @@ -304,8 +314,8 @@ class GetSSHKeys(Resource): else: # {user_prefix}/{realm}/{name}/key/{key_name} - etcd_key = os.path.join(USER_PREFIX, data["realm"], data["name"], - "key", data["key_name"]) + etcd_key = os.path.join(decouple.config("USER_PREFIX"), data["realm"], + data["name"], "key", data["key_name"]) etcd_entry = etcd_client.get(etcd_key, value_in_json=True) if etcd_entry: @@ -367,8 +377,25 @@ class CreateNetwork(Resource): network_entry = { "id": increment_etcd_counter(etcd_client, "/v1/counter/vxlan"), - "type": data["type"] + "type": data["type"], } + if validator.user.value: + nb = pynetbox.api(url=decouple.config("NETBOX_URL"), + token=decouple.config("NETBOX_TOKEN")) + nb_prefix = nb.ipam.prefixes.get(prefix=decouple.config("PREFIX")) + + prefix = nb_prefix.available_prefixes.create(data= + { + "prefix_length": decouple.config("PREFIX_LENGTH", cast=int), + "description": "{}'s network \"{}\"".format(data["name"], + data["network_name"]), + "is_pool": True + } + ) + network_entry["ipv6"] = prefix["prefix"] + else: + network_entry["ipv6"] = "fd00::/64" + network_key = os.path.join(NETWORK_PREFIX, data["name"], data["network_name"]) etcd_client.put(network_key, network_entry, value_in_json=True) return {"message": "Network successfully added."} diff --git a/api/schemas.py b/api/schemas.py index 8aab841..70aed2f 100755 --- a/api/schemas.py +++ b/api/schemas.py @@ -438,11 +438,12 @@ class CreateNetwork(OTPSchema): def __init__(self, data): self.network_name = Field("network_name", str, data.get("network_name", KeyError)) self.type = Field("type", str, data.get("type", KeyError)) + self.user = Field("user", bool, bool(data.get("user", False))) self.network_name.validation = self.network_name_validation self.type.validation = self.network_type_validation - fields = [self.network_name, self.type] + fields = [self.network_name, self.type, self.user] super().__init__(data, fields=fields) def network_name_validation(self): diff --git a/docs/Makefile b/docs/Makefile index 734e5ff..80cc2bb 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -7,10 +7,12 @@ SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = source/ BUILDDIR = build/ -DESTINATION=root@[2a0a:e5c0:2:12:0:f0ff:fea9:c3d9]:/home/app/static/ucloud +DESTINATION=root@staticweb.ungleich.ch:/home/services/www/ungleichstatic/staticcms.ungleich.ch/www/ucloud/ + +.PHONY: all build clean publish: build - rsync -av $(BUILDDIR)/ $(DESTINATION) + rsync -av $(BUILDDIR) $(DESTINATION) build: $(SPHINXBUILD) "$(SOURCEDIR)" "$(BUILDDIR)" diff --git a/docs/source/conf.py b/docs/source/conf.py index 197cfce..d08b5b4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -18,8 +18,8 @@ # -- Project information ----------------------------------------------------- project = 'ucloud' -copyright = '2019, Ahmed Bilal Khalid' -author = 'Ahmed Bilal Khalid' +copyright = '2019, ungleich' +author = 'ungleich' # -- General configuration --------------------------------------------------- @@ -28,6 +28,7 @@ author = 'Ahmed Bilal Khalid' # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ + 'sphinx.ext.autodoc' ] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/source/index.rst b/docs/source/index.rst index 28d7a53..8f96640 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,7 +12,9 @@ Welcome to ucloud's documentation! introduction/introduction introduction/installation - introduction/usage + usage/usage-for-admins + usage/usage-for-users + Indices and tables ================== diff --git a/docs/source/introduction/installation.rst b/docs/source/introduction/installation.rst index 4a67e04..3428f90 100644 --- a/docs/source/introduction/installation.rst +++ b/docs/source/introduction/installation.rst @@ -7,7 +7,7 @@ Installation The instructions assumes the following things * User is **root**. - * Base Directory is `/root/`. + * Base Directory is :file:`/root/`. Alpine ------ diff --git a/docs/source/introduction/introduction.rst b/docs/source/introduction/introduction.rst index 14d08d8..f45b3c1 100644 --- a/docs/source/introduction/introduction.rst +++ b/docs/source/introduction/introduction.rst @@ -1,5 +1,5 @@ -Introduction -============ +What is ucloud? +=============== **Open** + **Simple** + **Easy to hack** + **IPv6 First** @@ -18,6 +18,7 @@ Tech Stack * QEMU (+ kvm acceleration) as hypervisor. * etcd for key/value storage (specifically all metadata e.g Virtual Machine Specifications, Networks Specifications, Images Specifications etc.). * Ceph for image storage. +* uotp for user authentication. Components ---------- diff --git a/docs/source/introduction/usage.rst b/docs/source/usage/usage-for-admins.rst similarity index 65% rename from docs/source/introduction/usage.rst rename to docs/source/usage/usage-for-admins.rst index 325d868..b5a46a4 100644 --- a/docs/source/introduction/usage.rst +++ b/docs/source/usage/usage-for-admins.rst @@ -1,5 +1,5 @@ -Usage -===== +Usage Guide For Administrators +============================== Start API ---------- @@ -95,14 +95,14 @@ An image belongs to an image store. There are two types of store * Private Image Store (Not Implemented Yet) .. note:: - **Quick Quiz** Have we create an image store yet? + **Quick Quiz** Have we created an image store yet? -The answer is **No, we haven't**. Creating an example image store is very easy. +The answer is **No, we haven't**. Creating a sample image store is very easy. Just execute the following command .. code-block:: sh - pipenv run python ~/ucloud/api/create_image_store.py + (cd ~/ucloud && pipenv run python api/create_image_store.py) An image store (with name = "images") would be created. Now, we are fully ready for creating our very own image. Executing the following command to create image using the file uploaded earlier @@ -132,73 +132,3 @@ output something like the following } ] } - -Create VM ---------- - -The following command would create a Virtual Machine (name: meow) with following specs - -* CPU: 1 -* RAM: 1GB -* OS-SSD: 4GB -* OS: Alpine Linux - -.. code-block:: sh - - ucloud-cli vm create --vm-name meow --cpu 1 --ram '1gb' --os-ssd '4gb' --image images:alpine - -Check VM Status ---------------- - -.. code-block:: sh - - ucloud-cli vm status --vm-name meow - - -.. code-block:: json - - { - "hostname": "/v1/host/74c21c332f664972bf5078e8de080eea", - "image_uuid": "3f75bd20-45d6-4013-89c4-7fceaedc8dda", - "in_migration": null, - "log": [ - "2019-11-12T09:11:09.800798 - Started successfully" - ], - "metadata": { - "ssh-keys": [] - }, - "name": "meow", - "network": [], - "owner": "admin", - "owner_realm": "ungleich-admin", - "specs": { - "cpu": 1, - "hdd": [], - "os-ssd": "4.0 GB", - "ram": "1.0 GB" - }, - "status": "RUNNING", - "vnc_socket": "/tmp/tmpj1k6sdo_" - } - -Create Network --------------- - -.. code-block:: sh - - ucloud-cli network create --network-name mynet --network-type vxlan - - -.. code-block:: json - - { - "message": "Network successfully added." - } - -Create VM using this network - -.. code-block:: sh - - ucloud-cli vm create --vm-name meow2 --cpu 1 --ram '1gb' --os-ssd '4gb' --image images:alpine --network mynet - - diff --git a/docs/source/usage/usage-for-users.rst b/docs/source/usage/usage-for-users.rst new file mode 100644 index 0000000..39d6fce --- /dev/null +++ b/docs/source/usage/usage-for-users.rst @@ -0,0 +1,89 @@ +Usage Guide For End Users +========================= + +Create VM +--------- + +The following command would create a Virtual Machine (name: meow) with following specs + +* CPU: 1 +* RAM: 1GB +* OS-SSD: 4GB +* OS: Alpine Linux + +.. code-block:: sh + + ucloud-cli vm create --vm-name meow --cpu 1 --ram '1gb' --os-ssd '4gb' --image images:alpine + + +.. _how-to-check-vm-status: + +Check VM Status +--------------- + +.. code-block:: sh + + ucloud-cli vm status --vm-name meow + +.. code-block:: json + + { + "hostname": "/v1/host/74c21c332f664972bf5078e8de080eea", + "image_uuid": "3f75bd20-45d6-4013-89c4-7fceaedc8dda", + "in_migration": null, + "log": [ + "2019-11-12T09:11:09.800798 - Started successfully" + ], + "metadata": { + "ssh-keys": [] + }, + "name": "meow", + "network": [], + "owner": "admin", + "owner_realm": "ungleich-admin", + "specs": { + "cpu": 1, + "hdd": [], + "os-ssd": "4.0 GB", + "ram": "1.0 GB" + }, + "status": "RUNNING", + "vnc_socket": "/tmp/tmpj1k6sdo_" + } + + +Connect to VM using VNC +----------------------- + +We would need **socat** utility and a remote desktop client e.g Remmina, KRDC etc. +We can get the vnc socket path by getting its status, see :ref:`how-to-check-vm-status`. + + +.. code-block:: sh + + socat TCP-LISTEN:1234,reuseaddr,fork UNIX-CLIENT:/tmp/tmpj1k6sdo_ + + +Then, launch your remote desktop client and connect to vnc://localhost:1234. + +Create Network +-------------- + +.. code-block:: sh + + ucloud-cli network create --network-name mynet --network-type vxlan + + +.. code-block:: json + + { + "message": "Network successfully added." + } + +Create VM using this network + +.. code-block:: sh + + ucloud-cli vm create --vm-name meow2 --cpu 1 --ram '1gb' --os-ssd '4gb' --image images:alpine --network mynet + + diff --git a/host/virtualmachine.py b/host/virtualmachine.py index ef9c2cc..a989f3f 100755 --- a/host/virtualmachine.py +++ b/host/virtualmachine.py @@ -10,23 +10,26 @@ import subprocess as sp import tempfile import time import random +import ipaddress from functools import wraps from os.path import join from typing import Union -from decouple import config +from string import Template import bitmath import sshtunnel - import qmp -from config import (WITHOUT_CEPH, VM_PREFIX, VM_DIR, IMAGE_DIR, - NETWORK_PREFIX, etcd_client, logging, - request_pool, running_vms, vm_pool) + +from decouple import config from ucloud_common.helpers import get_ipv4_address from ucloud_common.request import RequestEntry, RequestType from ucloud_common.vm import VMEntry, VMStatus +from config import (WITHOUT_CEPH, VM_PREFIX, VM_DIR, IMAGE_DIR, + NETWORK_PREFIX, etcd_client, logging, + request_pool, running_vms, vm_pool) + class VM: def __init__(self, key, handle, vnc_socket_file): @@ -38,10 +41,12 @@ class VM: return "VM({})".format(self.key) -def create_dev(script, _id, dev): - assert isinstance(_id, str) and isinstance(dev, str), "_id and dev both must be string" +def create_dev(script, _id, dev, ip=None): + command = [script, _id, dev] + if ip: + command.append(ip) try: - output = sp.check_output([script, _id, dev], stderr=sp.PIPE) + output = sp.check_output(command, stderr=sp.PIPE) except Exception as e: print(e.stderr) return None @@ -49,13 +54,13 @@ def create_dev(script, _id, dev): return output.decode("utf-8").strip() -def create_vxlan_br_tap(_id, _dev): +def create_vxlan_br_tap(_id, _dev, ip=None): network_script_base = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'network') vxlan = create_dev(script=os.path.join(network_script_base, 'create-vxlan.sh'), _id=_id, dev=_dev) if vxlan: bridge = create_dev(script=os.path.join(network_script_base, 'create-bridge.sh'), - _id=_id, dev=vxlan) + _id=_id, dev=vxlan, ip=ip) if bridge: tap = create_dev(script=os.path.join(network_script_base, 'create-tap.sh'), _id=str(random.randint(1, 100000)), dev=bridge) @@ -85,6 +90,28 @@ def generate_mac(uaa=False, multicast=False, oui=None, separator=':', byte_fmt=' return separator.join(byte_fmt % b for b in mac) +def update_radvd_conf(etcd_client): + network_script_base = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'network') + + networks = { + net.value['ipv6']:net.value['id'] + for net in etcd_client.get_prefix('/v1/network/', value_in_json=True) + if net.value.get('ipv6') + } + radvd_template = open(os.path.join(network_script_base, + 'radvd-template.conf'), 'r').read() + radvd_template = Template(radvd_template) + + content = [radvd_template.safe_substitute(bridge='br{}'.format(networks[net]), + prefix=net) + for net in networks if networks.get(net)] + + with open('/etc/radvd.conf', 'w') as radvd_conf: + radvd_conf.writelines(content) + + sp.check_output(['systemctl', 'restart', 'radvd']) + + def get_start_command_args( vm_entry, vnc_sock_filename: str, migration=False, migration_port=4444, ): @@ -94,7 +121,6 @@ def get_start_command_args( vm_uuid = vm_entry.uuid vm_networks = vm_entry.network - if WITHOUT_CEPH: command = "-drive file={},format=raw,if=virtio,cache=none".format( os.path.join(VM_DIR, vm_uuid) @@ -114,18 +140,22 @@ def get_start_command_args( command += " -incoming tcp:0:{}".format(migration_port) tap = None - for network_name in vm_networks: + for network_and_mac in vm_networks: + network_name, mac = network_and_mac + _key = os.path.join(NETWORK_PREFIX, vm_entry.owner, network_name) network = etcd_client.get(_key, value_in_json=True) network_type = network.value["type"] network_id = str(network.value["id"]) + network_ipv6 = network.value["ipv6"] if network_type == "vxlan": - tap = create_vxlan_br_tap(network_id, config("VXLAN_PHY_DEV")) + tap = create_vxlan_br_tap(network_id, config("VXLAN_PHY_DEV"), network_ipv6) + update_radvd_conf(etcd_client) command += " -netdev tap,id=vmnet{net_id},ifname={tap},script=no,downscript=no"\ " -device virtio-net-pci,netdev=vmnet{net_id},mac={mac}"\ - .format(tap=tap, net_id=network_id, mac=generate_mac()) + .format(tap=tap, net_id=network_id, mac=mac) return command.split(" ") diff --git a/metadata/main.py b/metadata/main.py index 1c67768..22a4e62 100644 --- a/metadata/main.py +++ b/metadata/main.py @@ -9,7 +9,7 @@ api = Api(app) def get_vm_entry(mac_addr): - return next(filter(lambda vm: vm.mac == mac_addr, VM_POOL.vms), None) + return next(filter(lambda vm: mac_addr in list(zip(*vm.network))[1], VM_POOL.vms), None) # https://stackoverflow.com/questions/37140846/how-to-convert-ipv6-link-local-address-to-mac-address-in-python diff --git a/network/create-bridge.sh b/network/create-bridge.sh index 78ebbee..bdd8f75 100755 --- a/network/create-bridge.sh +++ b/network/create-bridge.sh @@ -1,14 +1,15 @@ #!/bin/sh -if [ $# -ne 2 ]; then - echo "$0 brid dev" - echo "f.g. $0 100 vxlan100" +if [ $# -ne 3 ]; then + echo "$0 brid dev ip" + echo "f.g. $0 100 vxlan100 fd00:/64" echo "Missing arguments" >&2 exit 1 fi brid=$1; shift dev=$1; shift +ip=$1; shift bridge=br${brid} sysctl net.ipv6.conf.all.forwarding=1 > /dev/null @@ -17,7 +18,7 @@ if ! ip link show $bridge > /dev/null 2> /dev/null; then ip link add name $bridge type bridge ip link set $bridge up ip link set $dev master $bridge - ip address add fd00:/64 dev $bridge + ip address add $ip dev $bridge fi echo $bridge \ No newline at end of file diff --git a/network/radvd-template.conf b/network/radvd-template.conf new file mode 100644 index 0000000..8afc9bd --- /dev/null +++ b/network/radvd-template.conf @@ -0,0 +1,13 @@ +interface $bridge +{ + AdvSendAdvert on; + MinRtrAdvInterval 3; + MaxRtrAdvInterval 5; + AdvDefaultLifetime 10; + + prefix $prefix { }; + + RDNSS 2a0a:e5c0:2:1::5 2a0a:e5c0:2:1::6 { AdvRDNSSLifetime 6000; }; + DNSSL place6.ungleich.ch { AdvDNSSLLifetime 6000; } ; +}; + From 21df2367bb3d5fb7648360c572a89563311e02b7 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 17 Nov 2019 18:49:40 +0100 Subject: [PATCH 012/284] [doc] Add guide on how to create VMs for ucloud --- docs/Makefile | 1 - docs/source/index.rst | 1 + .../how-to-create-an-os-image-for-ucloud.rst | 53 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 docs/source/usage/how-to-create-an-os-image-for-ucloud.rst diff --git a/docs/Makefile b/docs/Makefile index 80cc2bb..89a98ea 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -16,4 +16,3 @@ publish: build build: $(SPHINXBUILD) "$(SOURCEDIR)" "$(BUILDDIR)" - diff --git a/docs/source/index.rst b/docs/source/index.rst index 8f96640..b4dd510 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -14,6 +14,7 @@ Welcome to ucloud's documentation! introduction/installation usage/usage-for-admins usage/usage-for-users + usage/how-to-create-an-os-image-for-ucloud Indices and tables diff --git a/docs/source/usage/how-to-create-an-os-image-for-ucloud.rst b/docs/source/usage/how-to-create-an-os-image-for-ucloud.rst new file mode 100644 index 0000000..dad5f41 --- /dev/null +++ b/docs/source/usage/how-to-create-an-os-image-for-ucloud.rst @@ -0,0 +1,53 @@ +How to create VM images for ucloud +================================== + +Overview +--------- + +ucloud tries to be least invasise towards VMs and only require +strictly necessary changes for running in a virtualised +environment. This includes configurations for: + +* Configuring the network +* Managing access via ssh keys +* Resizing the attached disk(s) + + +Network configuration +--------------------- +All VMs in ucloud are required to support IPv6. The primary network +configuration is always done using SLAAC. A VM thus needs only to be +configured to + +* accept router advertisements on all network interfaces +* use the router advertisements to configure the network interfaces +* accept the DNS entries from the router advertisements + + +Configuring SSH keys +-------------------- + +To be able to access the VM, ucloud support provisioning SSH keys. + +To accept ssh keys in your VM, request the URL +*http://metadata/ssh_keys*. Add the content to the appropriate user's +**authorized_keys** file. Below you find sample code to accomplish +this task: + +.. code-block:: sh + + tmp=$(mktemp) + curl -s http://metadata/ssk_keys > "$tmp" + touch ~/.ssh/authorized_keys # ensure it exists + cat ~/.ssh/authorized_keys >> "$tmp" + sort "$tmp" | uniq > ~/.ssh/authorized_keys + + +Disk resize +----------- +In virtualised environments, the disk sizes might grow. The operating +system should detect disks that are bigger than the existing partition +table and resize accordingly. This task is os specific. + +ucloud does not support shrinking disks due to the complexity and +intra OS dependencies. From 1d2b980c74e24046885bff7b45f453a057552d77 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 17 Nov 2019 18:51:39 +0100 Subject: [PATCH 013/284] [doc] fix permissions before publishing --- docs/Makefile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/Makefile b/docs/Makefile index 89a98ea..a62df24 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -11,8 +11,12 @@ DESTINATION=root@staticweb.ungleich.ch:/home/services/www/ungleichstatic/staticc .PHONY: all build clean -publish: build +publish: build permissions rsync -av $(BUILDDIR) $(DESTINATION) +permissions: build + find $(BUILDDIR) -type f -exec chmod 0644 {} \; + find $(BUILDDIR) -type d -exec chmod 0755 {} \; + build: $(SPHINXBUILD) "$(SOURCEDIR)" "$(BUILDDIR)" From 6fa77bce4ded78eb55c958b86960d41c54cc6690 Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 18 Nov 2019 22:39:57 +0500 Subject: [PATCH 014/284] Remove ucloud_common and put its files under ucloud.common subpackage. Remove individual config.py used by every component and put them into single config.py ucloud/config.py Use /etc/ucloud/ucloud.conf for Environment Variables Refactoring and a lot of it Make ucloud repo a package and different components of ucloud a subpackage for avoiding code duplication. Improved logging. --- .gitignore | 3 +- Pipfile | 2 +- Pipfile.lock | 23 +- __init__.py | 0 api/__init__.py | 3 + api/common_fields.py | 5 +- api/config.py | 33 --- api/create_image_store.py | 6 +- api/helper.py | 63 ++--- api/main.py | 279 ++++++++++++------- api/schemas.py | 65 ++--- common/__init__.py | 0 common/classes.py | 48 ++++ common/counters.py | 21 ++ common/helpers.py | 39 +++ common/host.py | 67 +++++ common/request.py | 46 +++ common/vm.py | 110 ++++++++ config.py | 19 ++ docs/__init__.py | 0 docs/source/__init__.py | 0 docs/source/conf.py | 4 +- docs/source/index.rst | 8 +- docs/source/introduction/installation.rst | 3 + docs/source/misc/todo.rst | 7 + docs/source/usage/usage-for-admins.rst | 25 +- filescanner/__init__.py | 0 filescanner/main.py | 26 +- host/__init__.py | 3 + host/config.py | 36 --- host/main.py | 78 +++--- host/qmp/__init__.py | 16 +- host/qmp/__pycache__/__init__.cpython-37.pyc | Bin 15086 -> 0 bytes host/qmp/__pycache__/qmp.cpython-37.pyc | Bin 8571 -> 0 bytes host/qmp/qmp.py | 5 +- host/virtualmachine.py | 126 ++++----- imagescanner/__init__.py | 3 + imagescanner/config.py | 22 -- imagescanner/main.py | 48 ++-- metadata/__init__.py | 0 metadata/config.py | 22 -- metadata/main.py | 16 +- network/__init__.py | 0 scheduler/__init__.py | 3 + scheduler/config.py | 25 -- scheduler/helper.py | 25 +- scheduler/main.py | 40 +-- scheduler/tests/__init__.py | 0 scheduler/tests/test_basics.py | 10 +- scheduler/tests/test_dead_host_mechanism.py | 12 +- ucloud.py | 62 ++++- 51 files changed, 890 insertions(+), 567 deletions(-) create mode 100644 __init__.py create mode 100644 api/__init__.py delete mode 100644 api/config.py create mode 100644 common/__init__.py create mode 100644 common/classes.py create mode 100644 common/counters.py create mode 100644 common/helpers.py create mode 100644 common/host.py create mode 100644 common/request.py create mode 100644 common/vm.py create mode 100644 config.py create mode 100644 docs/__init__.py create mode 100644 docs/source/__init__.py create mode 100644 docs/source/misc/todo.rst create mode 100644 filescanner/__init__.py create mode 100644 host/__init__.py delete mode 100755 host/config.py delete mode 100755 host/qmp/__pycache__/__init__.cpython-37.pyc delete mode 100755 host/qmp/__pycache__/qmp.cpython-37.pyc create mode 100644 imagescanner/__init__.py delete mode 100755 imagescanner/config.py create mode 100644 metadata/__init__.py delete mode 100644 metadata/config.py create mode 100644 network/__init__.py create mode 100644 scheduler/__init__.py delete mode 100755 scheduler/config.py create mode 100644 scheduler/tests/__init__.py diff --git a/.gitignore b/.gitignore index 690d98e..55adfaf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,7 @@ .idea .vscode -.env __pycache__ docs/build -*/log.txt \ No newline at end of file +logs.txt \ No newline at end of file diff --git a/Pipfile b/Pipfile index 22120cd..ec5b001 100644 --- a/Pipfile +++ b/Pipfile @@ -5,6 +5,7 @@ verify_ssl = true [dev-packages] prospector = {extras = ["with_everything"],version = "*"} +pylama = "*" [packages] python-decouple = "*" @@ -12,7 +13,6 @@ requests = "*" flask = "*" flask-restful = "*" bitmath = "*" -ucloud-common = {editable = true,git = "git+https://code.ungleich.ch/ucloud/ucloud_common.git",ref = "wip"} etcd3-wrapper = {editable = true,git = "git+https://code.ungleich.ch/ungleich-public/etcd3_wrapper.git",ref = "wip"} python-etcd3 = {editable = true,git = "git+https://github.com/kragniz/python-etcd3.git"} pyotp = "*" diff --git a/Pipfile.lock b/Pipfile.lock index fa02525..b9373d5 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "5e4aa65086afdf9ac2f1479e9e35684f767dfbbd13877c4e4a23dd471aef6c13" + "sha256": "f43a93c020eb20212b437fcc62882db03bfa93f4678eb930e31343d687c805ed" }, "pipfile-spec": 6, "requires": { @@ -379,10 +379,10 @@ }, "pynetbox": { "hashes": [ - "sha256:e171380b36bedb7e0cd6a735fe8193d5809b373897b6905a2de43342761426c7" + "sha256:09525a29f1ac8c1a54772d6e2b94a55b1db6ba6a1c5b07f7af6a6ce232b1f7d5" ], "index": "pypi", - "version": "==4.0.8" + "version": "==4.1.0" }, "pyotp": { "hashes": [ @@ -401,10 +401,10 @@ }, "python-decouple": { "hashes": [ - "sha256:1317df14b43efee4337a4aa02914bf004f010cd56d6c4bd894e6474ec8c4fe2d" + "sha256:55c546b85b0c47a15a47a4312d451a437f7344a9be3e001660bccd93b637de95" ], "index": "pypi", - "version": "==3.1" + "version": "==3.3" }, "python-etcd3": { "editable": true, @@ -522,11 +522,6 @@ ], "version": "==6.0.0" }, - "ucloud-common": { - "editable": true, - "git": "https://code.ungleich.ch/ucloud/ucloud_common.git", - "ref": "eba92e5d6723093a3cc2999ae1f5c284e65dc809" - }, "urllib3": { "hashes": [ "sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293", @@ -775,6 +770,14 @@ ], "version": "==2.4.2" }, + "pylama": { + "hashes": [ + "sha256:9bae53ef9c1a431371d6a8dca406816a60d547147b60a4934721898f553b7d8f", + "sha256:fd61c11872d6256b019ef1235be37b77c922ef37ac9797df6bd489996dddeb15" + ], + "index": "pypi", + "version": "==7.7.1" + }, "pylint": { "hashes": [ "sha256:5d77031694a5fb97ea95e828c8d10fc770a1df6eb3906067aaed42201a8a6a09", diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..eea436a --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,3 @@ +import logging + +logger = logging.getLogger(__name__) diff --git a/api/common_fields.py b/api/common_fields.py index c2152c9..6a68763 100755 --- a/api/common_fields.py +++ b/api/common_fields.py @@ -1,7 +1,6 @@ import os -from config import etcd_client as client -from config import VM_PREFIX +from config import etcd_client, env_vars class Optional: @@ -49,6 +48,6 @@ class VmUUIDField(Field): self.validation = self.vm_uuid_validation def vm_uuid_validation(self): - r = client.get(os.path.join(VM_PREFIX, self.uuid)) + r = etcd_client.get(os.path.join(env_vars.get('VM_PREFIX'), self.uuid)) if not r: self.add_error("VM with uuid {} does not exists".format(self.uuid)) diff --git a/api/config.py b/api/config.py deleted file mode 100644 index b9e7b82..0000000 --- a/api/config.py +++ /dev/null @@ -1,33 +0,0 @@ -import logging - -from etcd3_wrapper import Etcd3Wrapper -from decouple import config - - -from ucloud_common.vm import VmPool -from ucloud_common.host import HostPool -from ucloud_common.request import RequestPool - -logging.basicConfig( - level=logging.DEBUG, - filename="log.txt", - filemode="a", - format="%(asctime)s: %(levelname)s - %(message)s", - datefmt="%d-%b-%y %H:%M:%S", -) - - -WITHOUT_CEPH = config("WITHOUT_CEPH", False, cast=bool) -VM_PREFIX = config("VM_PREFIX") -HOST_PREFIX = config("HOST_PREFIX") -REQUEST_PREFIX = config("REQUEST_PREFIX") -FILE_PREFIX = config("FILE_PREFIX") -IMAGE_PREFIX = config("IMAGE_PREFIX") -IMAGE_STORE_PREFIX = config("IMAGE_STORE_PREFIX") -NETWORK_PREFIX = config("NETWORK_PREFIX") - -etcd_client = Etcd3Wrapper(host=config("ETCD_URL")) - -VM_POOL = VmPool(etcd_client, VM_PREFIX) -HOST_POOL = HostPool(etcd_client, HOST_PREFIX) -REQUEST_POOL = RequestPool(etcd_client, REQUEST_PREFIX) diff --git a/api/create_image_store.py b/api/create_image_store.py index 796cc43..cddbacb 100755 --- a/api/create_image_store.py +++ b/api/create_image_store.py @@ -1,10 +1,8 @@ import json import os - from uuid import uuid4 -from config import etcd_client as client -from config import IMAGE_STORE_PREFIX +from config import etcd_client, env_vars data = { "is_public": True, @@ -14,4 +12,4 @@ data = { "attributes": {"list": [], "key": [], "pool": "images"}, } -client.put(os.path.join(IMAGE_STORE_PREFIX, uuid4().hex), json.dumps(data)) +etcd_client.put(os.path.join(env_vars.get('IMAGE_STORE_PREFIX'), uuid4().hex), json.dumps(data)) diff --git a/api/helper.py b/api/helper.py index 5f27c22..a45bd16 100755 --- a/api/helper.py +++ b/api/helper.py @@ -1,20 +1,20 @@ import binascii -import requests +import ipaddress import random import subprocess as sp -import ipaddress -from decouple import config +import requests from pyotp import TOTP -from config import VM_POOL, etcd_client, IMAGE_PREFIX + +from config import vm_pool, env_vars def check_otp(name, realm, token): try: data = { - "auth_name": config("AUTH_NAME", ""), - "auth_token": TOTP(config("AUTH_SEED", "")).now(), - "auth_realm": config("AUTH_REALM", ""), + "auth_name": env_vars.get("AUTH_NAME"), + "auth_token": TOTP(env_vars.get("AUTH_SEED")).now(), + "auth_realm": env_vars.get("AUTH_REALM"), "name": name, "realm": realm, "token": token, @@ -24,8 +24,8 @@ def check_otp(name, realm, token): response = requests.get( "{OTP_SERVER}{OTP_VERIFY_ENDPOINT}".format( - OTP_SERVER=config("OTP_SERVER", ""), - OTP_VERIFY_ENDPOINT=config("OTP_VERIFY_ENDPOINT", "verify"), + OTP_SERVER=env_vars.get("OTP_SERVER", ""), + OTP_VERIFY_ENDPOINT=env_vars.get("OTP_VERIFY_ENDPOINT", "verify"), ), json=data, ) @@ -41,7 +41,7 @@ def resolve_vm_name(name, owner): result = next( filter( lambda vm: vm.value["owner"] == owner and vm.value["name"] == name, - VM_POOL.vms, + vm_pool.vms, ), None, ) @@ -61,7 +61,7 @@ def resolve_image_name(name, etcd_client): """ seperator = ":" - + # Ensure, user/program passed valid name that is of type string try: store_name_and_image_name = name.split(seperator) @@ -79,7 +79,7 @@ def resolve_image_name(name, etcd_client): except Exception: raise ValueError("Image name not in correct format i.e {store_name}:{image_name}") - images = etcd_client.get_prefix(IMAGE_PREFIX, value_in_json=True) + images = etcd_client.get_prefix(env_vars.get('IMAGE_PREFIX'), value_in_json=True) # Try to find image with name == image_name and store_name == store_name try: @@ -89,7 +89,7 @@ def resolve_image_name(name, etcd_client): raise KeyError("No image with name {} found.".format(name)) else: image_uuid = image.key.split('/')[-1] - + return image_uuid @@ -102,16 +102,16 @@ def generate_mac(uaa=False, multicast=False, oui=None, separator=':', byte_fmt=' if oui: if type(oui) == str: oui = [int(chunk) for chunk in oui.split(separator)] - mac = oui + random_bytes(num=6-len(oui)) + mac = oui + random_bytes(num=6 - len(oui)) else: if multicast: - mac[0] |= 1 # set bit 0 + mac[0] |= 1 # set bit 0 else: - mac[0] &= ~1 # clear bit 0 + mac[0] &= ~1 # clear bit 0 if uaa: - mac[0] &= ~(1 << 1) # clear bit 1 + mac[0] &= ~(1 << 1) # clear bit 1 else: - mac[0] |= 1 << 1 # set bit 1 + mac[0] |= 1 << 1 # set bit 1 return separator.join(byte_fmt % b for b in mac) @@ -128,7 +128,7 @@ def get_ip_addr(mac_address, device): and is connected/neighbor of arg:device """ try: - output = sp.check_output(['ip','-6','neigh', 'show', 'dev', device], stderr=sp.PIPE) + output = sp.check_output(['ip', '-6', 'neigh', 'show', 'dev', device], stderr=sp.PIPE) except sp.CalledProcessError: return None else: @@ -145,25 +145,6 @@ def get_ip_addr(mac_address, device): return result -def increment_etcd_counter(etcd_client, key): - kv = etcd_client.get(key) - - if kv: - counter = int(kv.value) - counter = counter + 1 - else: - counter = 1 - - etcd_client.put(key, str(counter)) - return counter - - -def get_etcd_counter(etcd_client, key): - kv = etcd_client.get(key) - if kv: - return int(kv.value) - return None - def mac2ipv6(mac, prefix): # only accept MACs separated by a colon parts = mac.split(":") @@ -174,10 +155,10 @@ def mac2ipv6(mac, prefix): parts[0] = "%x" % (int(parts[0], 16) ^ 2) # format output - ipv6Parts = [str(0)]*4 + ipv6Parts = [str(0)] * 4 for i in range(0, len(parts), 2): - ipv6Parts.append("".join(parts[i:i+2])) - + ipv6Parts.append("".join(parts[i:i + 2])) + lower_part = ipaddress.IPv6Address(":".join(ipv6Parts)) prefix = ipaddress.IPv6Address(prefix) return str(prefix + int(lower_part)) diff --git a/api/main.py b/api/main.py index a5258cc..e621ce1 100644 --- a/api/main.py +++ b/api/main.py @@ -1,35 +1,19 @@ import json -import subprocess import os -import pynetbox -import decouple - -import schemas - +import subprocess from uuid import uuid4 +import pynetbox from flask import Flask, request from flask_restful import Resource, Api -from ucloud_common.vm import VMStatus -from ucloud_common.request import RequestEntry, RequestType - -from helper import (generate_mac, get_ip_addr, get_etcd_counter, - increment_etcd_counter, mac2ipv6) - -from config import ( - etcd_client, - WITHOUT_CEPH, - VM_PREFIX, - HOST_PREFIX, - FILE_PREFIX, - IMAGE_PREFIX, - NETWORK_PREFIX, - logging, - REQUEST_POOL, - VM_POOL, - HOST_POOL, -) +from common import counters +from common.request import RequestEntry, RequestType +from common.vm import VMStatus +from config import (etcd_client, request_pool, vm_pool, host_pool, env_vars) +from . import schemas +from .helper import generate_mac, mac2ipv6 +from api import logger app = Flask(__name__) api = Api(app) @@ -42,12 +26,12 @@ class CreateVM(Resource): validator = schemas.CreateVMSchema(data) if validator.is_valid(): vm_uuid = uuid4().hex - vm_key = os.path.join(VM_PREFIX, vm_uuid) + vm_key = os.path.join(env_vars.get("VM_PREFIX"), vm_uuid) specs = { - 'cpu': validator.specs['cpu'], - 'ram': validator.specs['ram'], - 'os-ssd': validator.specs['os-ssd'], - 'hdd': validator.specs['hdd'] + "cpu": validator.specs["cpu"], + "ram": validator.specs["ram"], + "os-ssd": validator.specs["os-ssd"], + "hdd": validator.specs["hdd"], } macs = [generate_mac() for i in range(len(data["network"]))] vm_entry = { @@ -61,15 +45,16 @@ class CreateVM(Resource): "log": [], "vnc_socket": "", "network": list(zip(data["network"], macs)), - "metadata": { - "ssh-keys": [] - }, + "metadata": {"ssh-keys": []}, } etcd_client.put(vm_key, vm_entry, value_in_json=True) # Create ScheduleVM Request - r = RequestEntry.from_scratch(type=RequestType.ScheduleVM, uuid=vm_uuid) - REQUEST_POOL.put(r) + r = RequestEntry.from_scratch( + type=RequestType.ScheduleVM, uuid=vm_uuid, + request_prefix=env_vars.get("REQUEST_PREFIX") + ) + request_pool.put(r) return {"message": "VM Creation Queued"}, 200 return validator.get_errors(), 400 @@ -81,13 +66,21 @@ class VmStatus(Resource): data = request.json validator = schemas.VMStatusSchema(data) if validator.is_valid(): - vm = VM_POOL.get(os.path.join(VM_PREFIX, data["uuid"])) + vm = vm_pool.get( + os.path.join(env_vars.get("VM_PREFIX"), data["uuid"]) + ) vm_value = vm.value.copy() vm_value["ip"] = [] for network_and_mac in vm.network: network_name, mac = network_and_mac - network = etcd_client.get(os.path.join(NETWORK_PREFIX, data["name"], network_name), - value_in_json=True) + network = etcd_client.get( + os.path.join( + env_vars.get("NETWORK_PREFIX"), + data["name"], + network_name, + ), + value_in_json=True, + ) ipv6_addr = network.value.get("ipv6").split("::")[0] + "::" vm_value["ip"].append(mac2ipv6(mac, ipv6_addr)) vm.value = vm_value @@ -102,7 +95,9 @@ class CreateImage(Resource): data = request.json validator = schemas.CreateImageSchema(data) if validator.is_valid(): - file_entry = etcd_client.get(os.path.join(FILE_PREFIX, data["uuid"])) + file_entry = etcd_client.get( + os.path.join(env_vars.get("FILE_PREFIX"), data["uuid"]) + ) file_entry_value = json.loads(file_entry.value) image_entry_json = { @@ -114,7 +109,8 @@ class CreateImage(Resource): "visibility": "public", } etcd_client.put( - os.path.join(IMAGE_PREFIX, data["uuid"]), json.dumps(image_entry_json) + os.path.join(env_vars.get("IMAGE_PREFIX"), data["uuid"]), + json.dumps(image_entry_json), ) return {"message": "Image queued for creation."} @@ -124,15 +120,18 @@ class CreateImage(Resource): class ListPublicImages(Resource): @staticmethod def get(): - images = etcd_client.get_prefix(IMAGE_PREFIX, value_in_json=True) + images = etcd_client.get_prefix( + env_vars.get("IMAGE_PREFIX"), value_in_json=True + ) r = {} r["images"] = [] for image in images: - image_key = "{}:{}".format(image.value["store_name"], image.value["name"]) - r["images"].append({ - "name":image_key, - "status": image.value["status"] - }) + image_key = "{}:{}".format( + image.value["store_name"], image.value["name"] + ) + r["images"].append( + {"name": image_key, "status": image.value["status"]} + ) return r, 200 @@ -143,34 +142,47 @@ class VMAction(Resource): validator = schemas.VmActionSchema(data) if validator.is_valid(): - vm_entry = VM_POOL.get(os.path.join(VM_PREFIX, data["uuid"])) + vm_entry = vm_pool.get( + os.path.join(env_vars.get("VM_PREFIX"), data["uuid"]) + ) action = data["action"] if action == "start": vm_entry.status = VMStatus.requested_start - VM_POOL.put(vm_entry) + vm_pool.put(vm_entry) action = "schedule" if action == "delete" and vm_entry.hostname == "": try: - path_without_protocol = vm_entry.path[vm_entry.path.find(":") + 1 :] + path_without_protocol = vm_entry.path[ + vm_entry.path.find(":") + 1: + ] - if WITHOUT_CEPH: + if env_vars.get("WITHOUT_CEPH"): command_to_delete = [ - "rm", "-rf", + "rm", + "-rf", os.path.join("/var/vm", vm_entry.uuid), ] else: - command_to_delete = ["rbd", "rm", path_without_protocol] + command_to_delete = [ + "rbd", + "rm", + path_without_protocol, + ] - subprocess.check_output(command_to_delete, stderr=subprocess.PIPE) + subprocess.check_output( + command_to_delete, stderr=subprocess.PIPE + ) except subprocess.CalledProcessError as e: if "No such file" in e.stderr.decode("utf-8"): etcd_client.client.delete(vm_entry.key) return {"message": "VM successfully deleted"} else: - logging.exception(e) - return {"message": "Some error occurred while deleting VM"} + logger.exception(e) + return { + "message": "Some error occurred while deleting VM" + } else: etcd_client.client.delete(vm_entry.key) return {"message": "VM successfully deleted"} @@ -179,8 +191,9 @@ class VMAction(Resource): type="{}VM".format(action.title()), uuid=data["uuid"], hostname=vm_entry.hostname, + request_prefix=env_vars.get("REQUEST_PREFIX") ) - REQUEST_POOL.put(r) + request_pool.put(r) return {"message": "VM {} Queued".format(action.title())}, 200 else: return validator.get_errors(), 400 @@ -193,15 +206,18 @@ class VMMigration(Resource): validator = schemas.VmMigrationSchema(data) if validator.is_valid(): - vm = VM_POOL.get(data["uuid"]) + vm = vm_pool.get(data["uuid"]) r = RequestEntry.from_scratch( type=RequestType.ScheduleVM, uuid=vm.uuid, - destination=os.path.join(HOST_PREFIX, data["destination"]), + destination=os.path.join( + env_vars.get("HOST_PREFIX"), data["destination"] + ), migration=True, + request_prefix=env_vars.get("REQUEST_PREFIX") ) - REQUEST_POOL.put(r) + request_pool.put(r) return {"message": "VM Migration Initialization Queued"}, 200 else: return validator.get_errors(), 400 @@ -214,7 +230,9 @@ class ListUserVM(Resource): validator = schemas.OTPSchema(data) if validator.is_valid(): - vms = etcd_client.get_prefix(VM_PREFIX, value_in_json=True) + vms = etcd_client.get_prefix( + env_vars.get("VM_PREFIX"), value_in_json=True + ) return_vms = [] user_vms = filter(lambda v: v.value["owner"] == data["name"], vms) for vm in user_vms: @@ -246,9 +264,13 @@ class ListUserFiles(Resource): validator = schemas.OTPSchema(data) if validator.is_valid(): - files = etcd_client.get_prefix(FILE_PREFIX, value_in_json=True) + files = etcd_client.get_prefix( + env_vars.get("FILE_PREFIX"), value_in_json=True + ) return_files = [] - user_files = list(filter(lambda f: f.value["owner"] == data["name"], files)) + user_files = list( + filter(lambda f: f.value["owner"] == data["name"], files) + ) for file in user_files: return_files.append( { @@ -267,7 +289,7 @@ class CreateHost(Resource): data = request.json validator = schemas.CreateHostSchema(data) if validator.is_valid(): - host_key = os.path.join(HOST_PREFIX, uuid4().hex) + host_key = os.path.join(env_vars.get("HOST_PREFIX"), uuid4().hex) host_entry = { "specs": data["specs"], "hostname": data["hostname"], @@ -284,7 +306,7 @@ class CreateHost(Resource): class ListHost(Resource): @staticmethod def get(): - hosts = HOST_POOL.hosts + hosts = host_pool.hosts r = { host.key: { "status": host.status, @@ -305,21 +327,38 @@ class GetSSHKeys(Resource): if not validator.key_name.value: # {user_prefix}/{realm}/{name}/key/ - etcd_key = os.path.join(decouple.config("USER_PREFIX"), data["realm"], - data["name"], "key") - etcd_entry = etcd_client.get_prefix(etcd_key, value_in_json=True) - - keys = {key.key.split("/")[-1]: key.value for key in etcd_entry} + etcd_key = os.path.join( + env_vars.get('USER_PREFIX'), + data["realm"], + data["name"], + "key", + ) + etcd_entry = etcd_client.get_prefix( + etcd_key, value_in_json=True + ) + + keys = { + key.key.split("/")[-1]: key.value for key in etcd_entry + } return {"keys": keys} else: # {user_prefix}/{realm}/{name}/key/{key_name} - etcd_key = os.path.join(decouple.config("USER_PREFIX"), data["realm"], - data["name"], "key", data["key_name"]) + etcd_key = os.path.join( + env_vars.get('USER_PREFIX'), + data["realm"], + data["name"], + "key", + data["key_name"], + ) etcd_entry = etcd_client.get(etcd_key, value_in_json=True) - + if etcd_entry: - return {"keys": {etcd_entry.key.split("/")[-1]: etcd_entry.value}} + return { + "keys": { + etcd_entry.key.split("/")[-1]: etcd_entry.value + } + } else: return {"keys": {}} else: @@ -332,13 +371,22 @@ class AddSSHKey(Resource): data = request.json validator = schemas.AddSSHSchema(data) if validator.is_valid(): - + # {user_prefix}/{realm}/{name}/key/{key_name} - etcd_key = os.path.join(USER_PREFIX, data["realm"], data["name"], - "key", data["key_name"]) + etcd_key = os.path.join( + env_vars.get("USER_PREFIX"), + data["realm"], + data["name"], + "key", + data["key_name"], + ) etcd_entry = etcd_client.get(etcd_key, value_in_json=True) if etcd_entry: - return {"message": "Key with name '{}' already exists".format(data["key_name"])} + return { + "message": "Key with name '{}' already exists".format( + data["key_name"] + ) + } else: # Key Not Found. It implies user' haven't added any key yet. etcd_client.put(etcd_key, data["key"], value_in_json=True) @@ -353,16 +401,25 @@ class RemoveSSHKey(Resource): data = request.json validator = schemas.RemoveSSHSchema(data) if validator.is_valid(): - + # {user_prefix}/{realm}/{name}/key/{key_name} - etcd_key = os.path.join(USER_PREFIX, data["realm"], data["name"], - "key", data["key_name"]) + etcd_key = os.path.join( + env_vars.get("USER_PREFIX"), + data["realm"], + data["name"], + "key", + data["key_name"], + ) etcd_entry = etcd_client.get(etcd_key, value_in_json=True) if etcd_entry: etcd_client.client.delete(etcd_key) return {"message": "Key successfully removed."} else: - return {"message": "No Key with name '{}' Exists at all.".format(data["key_name"])} + return { + "message": "No Key with name '{}' Exists at all.".format( + data["key_name"] + ) + } else: return validator.get_errors(), 400 @@ -374,29 +431,42 @@ class CreateNetwork(Resource): validator = schemas.CreateNetwork(data) if validator.is_valid(): - + network_entry = { - "id": increment_etcd_counter(etcd_client, "/v1/counter/vxlan"), + "id": counters.increment_etcd_counter( + etcd_client, "/v1/counter/vxlan" + ), "type": data["type"], } if validator.user.value: - nb = pynetbox.api(url=decouple.config("NETBOX_URL"), - token=decouple.config("NETBOX_TOKEN")) - nb_prefix = nb.ipam.prefixes.get(prefix=decouple.config("PREFIX")) + nb = pynetbox.api( + url=env_vars.get("NETBOX_URL"), + token=env_vars.get("NETBOX_TOKEN"), + ) + nb_prefix = nb.ipam.prefixes.get( + prefix=env_vars.get("PREFIX") + ) - prefix = nb_prefix.available_prefixes.create(data= - { - "prefix_length": decouple.config("PREFIX_LENGTH", cast=int), - "description": "{}'s network \"{}\"".format(data["name"], - data["network_name"]), - "is_pool": True + prefix = nb_prefix.available_prefixes.create( + data={ + "prefix_length": env_vars.get( + "PREFIX_LENGTH", cast=int + ), + "description": '{}\'s network "{}"'.format( + data["name"], data["network_name"] + ), + "is_pool": True, } ) network_entry["ipv6"] = prefix["prefix"] else: network_entry["ipv6"] = "fd00::/64" - - network_key = os.path.join(NETWORK_PREFIX, data["name"], data["network_name"]) + + network_key = os.path.join( + env_vars.get("NETWORK_PREFIX"), + data["name"], + data["network_name"], + ) etcd_client.put(network_key, network_entry, value_in_json=True) return {"message": "Network successfully added."} else: @@ -410,7 +480,9 @@ class ListUserNetwork(Resource): validator = schemas.OTPSchema(data) if validator.is_valid(): - prefix = os.path.join(NETWORK_PREFIX, data["name"]) + prefix = os.path.join( + env_vars.get("NETWORK_PREFIX"), data["name"] + ) networks = etcd_client.get_prefix(prefix, value_in_json=True) user_networks = [] for net in networks: @@ -443,5 +515,20 @@ api.add_resource(ListHost, "/host/list") api.add_resource(CreateNetwork, "/network/create") -if __name__ == "__main__": + +def main(): + data = { + "is_public": True, + "type": "ceph", + "name": "images", + "description": "first ever public image-store", + "attributes": {"list": [], "key": [], "pool": "images"}, + } + + etcd_client.put(os.path.join(env_vars.get('IMAGE_STORE_PREFIX'), uuid4().hex), json.dumps(data)) + app.run(host="::", debug=True) + + +if __name__ == "__main__": + main() diff --git a/api/schemas.py b/api/schemas.py index 70aed2f..28a1bc1 100755 --- a/api/schemas.py +++ b/api/schemas.py @@ -16,21 +16,15 @@ inflexible for our purpose. import json import os + import bitmath -import helper - -from ucloud_common.host import HostPool, HostStatus -from ucloud_common.vm import VmPool, VMStatus - -from common_fields import Field, VmUUIDField, Optional -from helper import check_otp, resolve_vm_name -from config import etcd_client as client -from config import (HOST_PREFIX, VM_PREFIX, IMAGE_PREFIX, - FILE_PREFIX, IMAGE_STORE_PREFIX, NETWORK_PREFIX) - -HOST_POOL = HostPool(client, HOST_PREFIX) -VM_POOL = VmPool(client, VM_PREFIX) +from common.host import HostStatus +from common.vm import VMStatus +from config import etcd_client, env_vars, vm_pool, host_pool +from . import helper +from .common_fields import Field, VmUUIDField +from .helper import check_otp, resolve_vm_name class BaseSchema: @@ -108,14 +102,14 @@ class CreateImageSchema(BaseSchema): super().__init__(data, fields) def file_uuid_validation(self): - file_entry = client.get(os.path.join(FILE_PREFIX, self.uuid.value)) + file_entry = etcd_client.get(os.path.join(env_vars.get('FILE_PREFIX'), self.uuid.value)) if file_entry is None: self.add_error( "Image File with uuid '{}' Not Found".format(self.uuid.value) ) def image_store_name_validation(self): - image_stores = list(client.get_prefix(IMAGE_STORE_PREFIX)) + image_stores = list(etcd_client.get_prefix(env_vars.get('IMAGE_STORE_PREFIX'))) image_store = next( filter( @@ -205,7 +199,7 @@ class CreateHostSchema(OTPSchema): class CreateVMSchema(OTPSchema): def __init__(self, data): self.parsed_specs = {} - + # Fields self.specs = Field("specs", dict, data.get("specs", KeyError)) self.vm_name = Field("vm_name", str, data.get("vm_name", KeyError)) @@ -222,10 +216,9 @@ class CreateVMSchema(OTPSchema): super().__init__(data=data, fields=fields) - def image_validation(self): try: - image_uuid = helper.resolve_image_name(self.image.value, client) + image_uuid = helper.resolve_image_name(self.image.value, etcd_client) except Exception as e: self.add_error(str(e)) else: @@ -237,17 +230,17 @@ class CreateVMSchema(OTPSchema): 'VM with same name "{}" already exists'.format(self.vm_name.value) ) - def network_validation(self): + def network_validation(self): _network = self.network.value if _network: for net in _network: - network = client.get(os.path.join(NETWORK_PREFIX, - self.name.value, - net), value_in_json=True) + network = etcd_client.get(os.path.join(env_vars.get('NETWORK_PREFIX'), + self.name.value, + net), value_in_json=True) if not network: - self.add_error("Network with name {} does not exists"\ - .format(net)) + self.add_error("Network with name {} does not exists" \ + .format(net)) def specs_validation(self): ALLOWED_BASE = 10 @@ -316,7 +309,7 @@ class VMStatusSchema(OTPSchema): super().__init__(data, fields) def validation(self): - vm = VM_POOL.get(self.uuid.value) + vm = vm_pool.get(self.uuid.value) if not ( vm.value["owner"] == self.name.value or self.realm.value == "ungleich-admin" ): @@ -349,7 +342,7 @@ class VmActionSchema(OTPSchema): ) def validation(self): - vm = VM_POOL.get(self.uuid.value) + vm = vm_pool.get(self.uuid.value) if not ( vm.value["owner"] == self.name.value or self.realm.value == "ungleich-admin" ): @@ -389,14 +382,14 @@ class VmMigrationSchema(OTPSchema): def destination_validation(self): host_key = self.destination.value - host = HOST_POOL.get(host_key) + host = host_pool.get(host_key) if not host: self.add_error("No Such Host ({}) exists".format(self.destination.value)) elif host.status != HostStatus.alive: self.add_error("Destination Host is dead") def validation(self): - vm = VM_POOL.get(self.uuid.value) + vm = vm_pool.get(self.uuid.value) if not ( vm.value["owner"] == self.name.value or self.realm.value == "ungleich-admin" ): @@ -405,7 +398,7 @@ class VmMigrationSchema(OTPSchema): if vm.status != VMStatus.running: self.add_error("Can't migrate non-running VM") - if vm.hostname == os.path.join(HOST_PREFIX, self.destination.value): + if vm.hostname == os.path.join(env_vars.get('HOST_PREFIX'), self.destination.value): self.add_error("Destination host couldn't be same as Source Host") @@ -445,15 +438,15 @@ class CreateNetwork(OTPSchema): fields = [self.network_name, self.type, self.user] super().__init__(data, fields=fields) - + def network_name_validation(self): - network = client.get(os.path.join(NETWORK_PREFIX, - self.name.value, - self.network_name.value), - value_in_json=True) + network = etcd_client.get(os.path.join(env_vars.get('NETWORK_PREFIX'), + self.name.value, + self.network_name.value), + value_in_json=True) if network: - self.add_error("Network with name {} already exists"\ - .format(self.network_name.value)) + self.add_error("Network with name {} already exists" \ + .format(self.network_name.value)) def network_type_validation(self): supported_network_types = ["vxlan"] diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/classes.py b/common/classes.py new file mode 100644 index 0000000..2cea033 --- /dev/null +++ b/common/classes.py @@ -0,0 +1,48 @@ +from decouple import Config, RepositoryEnv, UndefinedValueError +from etcd3_wrapper import EtcdEntry + + +class EnvironmentVariables: + def __init__(self, env_file): + try: + env_config = Config(RepositoryEnv(env_file)) + except FileNotFoundError: + print("{} does not exists".format(env_file)) + exit(1) + else: + self.config = env_config + + def get(self, *args, **kwargs): + """Return value of var from env_vars""" + try: + value = self.config.get(*args, **kwargs) + except UndefinedValueError as e: + print(e) + exit(1) + else: + return value + + +class SpecificEtcdEntryBase: + def __init__(self, e: EtcdEntry): + self.key = e.key + + for k in e.value.keys(): + self.__setattr__(k, e.value[k]) + + def original_keys(self): + r = dict(self.__dict__) + if "key" in r: + del r["key"] + return r + + @property + def value(self): + return self.original_keys() + + @value.setter + def value(self, v): + self.__dict__ = v + + def __repr__(self): + return str(dict(self.__dict__)) diff --git a/common/counters.py b/common/counters.py new file mode 100644 index 0000000..066a870 --- /dev/null +++ b/common/counters.py @@ -0,0 +1,21 @@ +from etcd3_wrapper import Etcd3Wrapper + + +def increment_etcd_counter(etcd_client: Etcd3Wrapper, key): + kv = etcd_client.get(key) + + if kv: + counter = int(kv.value) + counter = counter + 1 + else: + counter = 1 + + etcd_client.put(key, str(counter)) + return counter + + +def get_etcd_counter(etcd_client: Etcd3Wrapper, key): + kv = etcd_client.get(key) + if kv: + return int(kv.value) + return None diff --git a/common/helpers.py b/common/helpers.py new file mode 100644 index 0000000..c0d64e4 --- /dev/null +++ b/common/helpers.py @@ -0,0 +1,39 @@ +import logging +import socket + +from os.path import join as join_path + + +def create_package_loggers(packages, base_path, mode="a"): + loggers = {} + for pkg in packages: + logger = logging.getLogger(pkg) + logger_handler = logging.FileHandler( + join_path(base_path, "{}.txt".format(pkg)), + mode=mode + ) + logger.setLevel(logging.DEBUG) + logger_handler.setFormatter(logging.Formatter(fmt="%(asctime)s: %(levelname)s - %(message)s", + datefmt="%d-%b-%y %H:%M:%S")) + logger.addHandler(logger_handler) + loggers[pkg] = logger + + +# TODO: Should be removed as soon as migration +# mechanism is finalized inside ucloud +def get_ipv4_address(): + # If host is connected to internet + # Return IPv4 address of machine + # Otherwise, return 127.0.0.1 + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + try: + s.connect(("8.8.8.8", 80)) + except socket.timeout: + address = "127.0.0.1" + except Exception as e: + logging.getLogger().exception(e) + address = "127.0.0.1" + else: + address = s.getsockname()[0] + + return address diff --git a/common/host.py b/common/host.py new file mode 100644 index 0000000..ccbf7a8 --- /dev/null +++ b/common/host.py @@ -0,0 +1,67 @@ +import time +from datetime import datetime +from os.path import join +from typing import List + +from .classes import SpecificEtcdEntryBase + + +class HostStatus: + """Possible Statuses of ucloud host.""" + + alive = "ALIVE" + dead = "DEAD" + + +class HostEntry(SpecificEtcdEntryBase): + """Represents Host Entry Structure and its supporting methods.""" + + def __init__(self, e): + self.specs = None # type: dict + self.hostname = None # type: str + self.status = None # type: str + self.last_heartbeat = None # type: str + + super().__init__(e) + + def update_heartbeat(self): + self.status = HostStatus.alive + self.last_heartbeat = time.strftime("%Y-%m-%d %H:%M:%S") + + def is_alive(self): + last_heartbeat = datetime.strptime(self.last_heartbeat, "%Y-%m-%d %H:%M:%S") + delta = datetime.now() - last_heartbeat + if delta.total_seconds() > 60: + return False + return True + + def declare_dead(self): + self.status = HostStatus.dead + self.last_heartbeat = time.strftime("%Y-%m-%d %H:%M:%S") + + +class HostPool: + def __init__(self, etcd_client, host_prefix): + self.client = etcd_client + self.prefix = host_prefix + + @property + def hosts(self) -> List[HostEntry]: + _hosts = self.client.get_prefix(self.prefix, value_in_json=True) + return [HostEntry(host) for host in _hosts] + + def get(self, key): + if not key.startswith(self.prefix): + key = join(self.prefix, key) + v = self.client.get(key, value_in_json=True) + if v: + return HostEntry(v) + return None + + def put(self, obj: HostEntry): + self.client.put(obj.key, obj.value, value_in_json=True) + + def by_status(self, status, _hosts=None): + if _hosts is None: + _hosts = self.hosts + return list(filter(lambda x: x.status == status, _hosts)) diff --git a/common/request.py b/common/request.py new file mode 100644 index 0000000..cadac80 --- /dev/null +++ b/common/request.py @@ -0,0 +1,46 @@ +import json +from os.path import join +from uuid import uuid4 + +from etcd3_wrapper.etcd3_wrapper import PsuedoEtcdEntry + +from .classes import SpecificEtcdEntryBase + + +class RequestType: + CreateVM = "CreateVM" + ScheduleVM = "ScheduleVM" + StartVM = "StartVM" + StopVM = "StopVM" + InitVMMigration = "InitVMMigration" + TransferVM = "TransferVM" + DeleteVM = "DeleteVM" + + +class RequestEntry(SpecificEtcdEntryBase): + + def __init__(self, e): + self.type = None # type: str + self.migration = None # type: bool + self.destination = None # type: str + self.uuid = None # type: str + self.hostname = None # type: str + super().__init__(e) + + @classmethod + def from_scratch(cls, request_prefix, **kwargs): + e = PsuedoEtcdEntry(join(request_prefix, uuid4().hex), + value=json.dumps(kwargs).encode("utf-8"), value_in_json=True) + return cls(e) + + +class RequestPool: + def __init__(self, etcd_client, request_prefix): + self.client = etcd_client + self.prefix = request_prefix + + def put(self, obj: RequestEntry): + if not obj.key.startswith(self.prefix): + obj.key = join(self.prefix, obj.key) + + self.client.put(obj.key, obj.value, value_in_json=True) diff --git a/common/vm.py b/common/vm.py new file mode 100644 index 0000000..c778fac --- /dev/null +++ b/common/vm.py @@ -0,0 +1,110 @@ +from contextlib import contextmanager +from datetime import datetime +from os.path import join + +from .classes import SpecificEtcdEntryBase + + +class VMStatus: + # Must be only assigned to brand new VM + requested_new = "REQUESTED_NEW" + + # Only Assigned to already created vm + requested_start = "REQUESTED_START" + + # These all are for running vms + requested_shutdown = "REQUESTED_SHUTDOWN" + requested_migrate = "REQUESTED_MIGRATE" + requested_delete = "REQUESTED_DELETE" + # either its image is not found or user requested + # to delete it + deleted = "DELETED" + + stopped = "STOPPED" # After requested_shutdown + killed = "KILLED" # either host died or vm died itself + + running = "RUNNING" + + error = "ERROR" # An error occurred that cannot be resolved automatically + + +class VMEntry(SpecificEtcdEntryBase): + + def __init__(self, e): + self.owner = None # type: str + self.specs = None # type: dict + self.hostname = None # type: str + self.status = None # type: str + self.image_uuid = None # type: str + self.log = None # type: list + self.in_migration = None # type: bool + + super().__init__(e) + + @property + def uuid(self): + return self.key.split("/")[-1] + + def declare_killed(self): + self.hostname = "" + self.in_migration = False + if self.status == VMStatus.running: + self.status = VMStatus.killed + + def declare_stopped(self): + self.hostname = "" + self.in_migration = False + self.status = VMStatus.stopped + + def add_log(self, msg): + self.log = self.log[:5] + self.log.append("{} - {}".format(datetime.now().isoformat(), msg)) + + @property + def path(self): + return "rbd:uservms/{}".format(self.uuid) + + +class VmPool: + def __init__(self, etcd_client, vm_prefix): + self.client = etcd_client + self.prefix = vm_prefix + + @property + def vms(self): + _vms = self.client.get_prefix(self.prefix, value_in_json=True) + return [VMEntry(vm) for vm in _vms] + + def by_host(self, host, _vms=None): + if _vms is None: + _vms = self.vms + return list(filter(lambda x: x.hostname == host, _vms)) + + def by_status(self, status, _vms=None): + if _vms is None: + _vms = self.vms + return list(filter(lambda x: x.status == status, _vms)) + + def except_status(self, status, _vms=None): + if _vms is None: + _vms = self.vms + return list(filter(lambda x: x.status != status, _vms)) + + def get(self, key): + if not key.startswith(self.prefix): + key = join(self.prefix, key) + v = self.client.get(key, value_in_json=True) + if v: + return VMEntry(v) + return None + + def put(self, obj: VMEntry): + self.client.put(obj.key, obj.value, value_in_json=True) + + @contextmanager + def get_put(self, key) -> VMEntry: + # Updates object at key on exit + obj = self.get(key) + yield obj + if obj: + self.put(obj) diff --git a/config.py b/config.py new file mode 100644 index 0000000..5729fed --- /dev/null +++ b/config.py @@ -0,0 +1,19 @@ +from etcd3_wrapper import Etcd3Wrapper + +from common.classes import EnvironmentVariables +from common.host import HostPool +from common.request import RequestPool +from common.vm import VmPool + +env_vars = EnvironmentVariables('/etc/ucloud/ucloud.conf') + +etcd_wrapper_args = () +etcd_wrapper_kwargs = {"host": env_vars.get("ETCD_URL")} + +etcd_client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) + +host_pool = HostPool(etcd_client, env_vars.get('HOST_PREFIX')) +vm_pool = VmPool(etcd_client, env_vars.get('VM_PREFIX')) +request_pool = RequestPool(etcd_client, env_vars.get('REQUEST_PREFIX')) + +running_vms = [] diff --git a/docs/__init__.py b/docs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/__init__.py b/docs/source/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/conf.py b/docs/source/conf.py index d08b5b4..64509c4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -21,7 +21,6 @@ project = 'ucloud' copyright = '2019, ungleich' author = 'ungleich' - # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be @@ -39,7 +38,6 @@ templates_path = ['_templates'] # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] - # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for @@ -50,4 +48,4 @@ html_theme = 'alabaster' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] \ No newline at end of file +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst index b4dd510..0307de8 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,7 +1,7 @@ .. ucloud documentation master file, created by - sphinx-quickstart on Mon Nov 11 19:08:16 2019. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. +sphinx-quickstart on Mon Nov 11 19:08:16 2019. +You can adapt this file completely to your liking, but it should at least +contain the root `toctree` directive. Welcome to ucloud's documentation! ================================== @@ -15,7 +15,7 @@ Welcome to ucloud's documentation! usage/usage-for-admins usage/usage-for-users usage/how-to-create-an-os-image-for-ucloud - + misc/todo Indices and tables ================== diff --git a/docs/source/introduction/installation.rst b/docs/source/introduction/installation.rst index 3428f90..b271ab9 100644 --- a/docs/source/introduction/installation.rst +++ b/docs/source/introduction/installation.rst @@ -36,6 +36,9 @@ Enable Edge Repos, Update and Upgrade Install Dependencies ~~~~~~~~~~~~~~~~~~~~ +.. note:: + The installation and configuration of a production grade etcd cluster + is out of scope of this manual. So, we will install etcd with default configuration. .. code-block:: sh :linenos: diff --git a/docs/source/misc/todo.rst b/docs/source/misc/todo.rst new file mode 100644 index 0000000..3b85e89 --- /dev/null +++ b/docs/source/misc/todo.rst @@ -0,0 +1,7 @@ +TODO +==== + +* Check for :code:`etcd3.exceptions.ConnectionFailedError` when calling some etcd operation to + avoid crashing whole application. +* Throw KeyError instead of returning None when some key is not found in etcd. +* Expose more details in ListUserFiles. \ No newline at end of file diff --git a/docs/source/usage/usage-for-admins.rst b/docs/source/usage/usage-for-admins.rst index b5a46a4..3c20fb4 100644 --- a/docs/source/usage/usage-for-admins.rst +++ b/docs/source/usage/usage-for-admins.rst @@ -55,7 +55,28 @@ To start host we created earlier, execute the following command Create OS Image --------------- -First, we need to upload the file. + +Create ucloud-init ready OS image (Optional) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +This step is optional if you just want to test ucloud. However, sooner or later +you want to create OS images with ucloud-init to properly +contexualize VMs. + +1. Start a VM with OS image on which you want to install ucloud-init +2. Execute the following command on the started VM + + .. code-block:: sh + + apk add git + git clone https://code.ungleich.ch/ucloud/ucloud-init.git + cd ucloud-init + sh ./install.sh +3. Congratulations. Your image is now ucloud-init ready. + + +Upload Sample OS Image +~~~~~~~~~~~~~~~~~~~~~~ +Execute the following to upload the sample OS image file. .. code-block:: sh @@ -63,7 +84,7 @@ First, we need to upload the file. (cd /var/www/admin && wget http://[2a0a:e5c0:2:12:0:f0ff:fea9:c3d9]/alpine-untouched.qcow2) Run File Scanner and Image Scanner ------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Currently, our uploaded file *alpine-untouched.qcow2* is not tracked by ucloud. We can only make images from tracked files. So, we need to track the file by running File Scanner diff --git a/filescanner/__init__.py b/filescanner/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/filescanner/main.py b/filescanner/main.py index 6495886..d1ffa46 100755 --- a/filescanner/main.py +++ b/filescanner/main.py @@ -1,14 +1,13 @@ -import os import glob +import os import pathlib -import time -import hashlib import subprocess as sp - -from decouple import config -from etcd3_wrapper import Etcd3Wrapper +import time from uuid import uuid4 +from etcd3_wrapper import Etcd3Wrapper + +from config import env_vars def getxattr(file, attr): @@ -22,7 +21,7 @@ def getxattr(file, attr): value = value.decode("utf-8") except sp.CalledProcessError: value = None - + return value @@ -32,8 +31,8 @@ def setxattr(file, attr, value): attr = "user." + attr sp.check_output(['setfattr', file, - '--name', attr, - '--value', str(value)]) + '--name', attr, + '--value', str(value)]) def sha512sum(file: str): @@ -68,12 +67,13 @@ except Exception as e: print('Make sure you have getfattr and setfattr available') exit(1) + def main(): - BASE_DIR = config("BASE_DIR") + BASE_DIR = env_vars.get("BASE_DIR") - FILE_PREFIX = config("FILE_PREFIX") + FILE_PREFIX = env_vars.get("FILE_PREFIX") - etcd_client = Etcd3Wrapper(host=config("ETCD_URL")) + etcd_client = Etcd3Wrapper(host=env_vars.get("ETCD_URL")) # Recursively Get All Files and Folder below BASE_DIR files = glob.glob("{}/**".format(BASE_DIR), recursive=True) @@ -125,4 +125,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/host/__init__.py b/host/__init__.py new file mode 100644 index 0000000..eea436a --- /dev/null +++ b/host/__init__.py @@ -0,0 +1,3 @@ +import logging + +logger = logging.getLogger(__name__) diff --git a/host/config.py b/host/config.py deleted file mode 100755 index c2dbb06..0000000 --- a/host/config.py +++ /dev/null @@ -1,36 +0,0 @@ -import logging - -from etcd3_wrapper import Etcd3Wrapper -from ucloud_common.vm import VmPool -from ucloud_common.host import HostPool -from ucloud_common.request import RequestPool -from decouple import config - -WITHOUT_CEPH = config("WITHOUT_CEPH", False, cast=bool) - -logging.basicConfig( - level=logging.DEBUG, - filename="log.txt", - filemode="a", - format="%(asctime)s: %(levelname)s - %(message)s", - datefmt="%d-%b-%y %H:%M:%S", -) - -etcd_wrapper_args = () -etcd_wrapper_kwargs = {"host": config("ETCD_URL")} - -etcd_client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) - -HOST_PREFIX = config("HOST_PREFIX") -NETWORK_PREFIX = config("NETWORK_PREFIX") -VM_PREFIX = config("VM_PREFIX") -REQUEST_PREFIX = config("REQUEST_PREFIX") -VM_DIR = config("VM_DIR") -IMAGE_DIR = config("IMAGE_DIR") - - -host_pool = HostPool(etcd_client, HOST_PREFIX) -vm_pool = VmPool(etcd_client, VM_PREFIX) -request_pool = RequestPool(etcd_client, REQUEST_PREFIX) - -running_vms = [] diff --git a/host/main.py b/host/main.py index 9d9f396..5b5e620 100755 --- a/host/main.py +++ b/host/main.py @@ -1,31 +1,29 @@ import argparse -import time -import os -import sys -import virtualmachine import multiprocessing as mp +import os +import time -from ucloud_common.host import HostEntry -from ucloud_common.request import RequestEntry, RequestType - -from config import (vm_pool, host_pool, request_pool, - etcd_client, logging, running_vms, - etcd_wrapper_args, etcd_wrapper_kwargs, - REQUEST_PREFIX, HOST_PREFIX, - WITHOUT_CEPH, VM_DIR, HostPool) from etcd3_wrapper import Etcd3Wrapper -import etcd3 + +from common.request import RequestEntry, RequestType +from config import (vm_pool, request_pool, + etcd_client, running_vms, + etcd_wrapper_args, etcd_wrapper_kwargs, + HostPool, env_vars) +from . import virtualmachine +from host import logger def update_heartbeat(host): client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) - host_pool = HostPool(client, HOST_PREFIX) + host_pool = HostPool(client, env_vars.get('HOST_PREFIX')) this_host = next(filter(lambda h: h.hostname == host, host_pool.hosts), None) - + while True: this_host.update_heartbeat() host_pool.put(this_host) time.sleep(10) + def maintenance(host): # To capture vm running according to running_vms list @@ -65,31 +63,25 @@ def maintenance(host): running_vms.remove(_vm) -def main(): - argparser = argparse.ArgumentParser() - argparser.add_argument("hostname", help="Name of this host. e.g /v1/host/1") - args = argparser.parse_args() +def main(hostname): + assert env_vars.get('WITHOUT_CEPH') and os.path.isdir(env_vars.get('VM_DIR')), ( + "You have set env_vars.get('WITHOUT_CEPH') to True. So, the vm directory mentioned" + " in .env file must exists. But, it don't.") - assert WITHOUT_CEPH and os.path.isdir(VM_DIR), ( - "You have set WITHOUT_CEPH to True. So, the vm directory mentioned" - " in .env file must exists. But, it don't." ) - - mp.set_start_method('spawn') - heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(args.hostname,)) + heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(hostname,)) - host_pool = HostPool(etcd_client, HOST_PREFIX) - host = next(filter(lambda h: h.hostname == args.hostname, host_pool.hosts), None) + host_pool = HostPool(etcd_client, env_vars.get('HOST_PREFIX')) + host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) assert host is not None, "No such host" try: heartbeat_updating_process.start() except Exception as e: - logging.info("No Need To Go Further. Our heartbeat updating mechanism is not working") - logging.exception(e) + logger.info("No Need To Go Further. Our heartbeat updating mechanism is not working") + logger.exception(e) exit(-1) - - logging.info("%s Session Started %s", '*' * 5, '*' * 5) + logger.info("%s Session Started %s", '*' * 5, '*' * 5) # It is seen that under heavy load, timeout event doesn't come # in a predictive manner (which is intentional because we give @@ -99,22 +91,21 @@ def main(): # update the heart beat in a predictive manner we start Heart # beat updating mechanism in separated thread - for events_iterator in [ - etcd_client.get_prefix(REQUEST_PREFIX, value_in_json=True), - etcd_client.watch_prefix(REQUEST_PREFIX, timeout=10, value_in_json=True), + etcd_client.get_prefix(env_vars.get('REQUEST_PREFIX'), value_in_json=True), + etcd_client.watch_prefix(env_vars.get('REQUEST_PREFIX'), timeout=10, value_in_json=True), ]: for request_event in events_iterator: request_event = RequestEntry(request_event) if request_event.type == "TIMEOUT": - logging.info("Timeout Event") + logger.info("Timeout Event") maintenance(host) continue # If the event is directed toward me OR I am destination of a InitVMMigration - if (request_event.hostname == host.key or request_event.destination == host.key): - logging.debug("EVENT: %s", request_event) + if request_event.hostname == host.key or request_event.destination == host.key: + logger.debug("EVENT: %s", request_event) request_pool.client.client.delete(request_event.key) vm_entry = vm_pool.get(request_event.uuid) @@ -135,11 +126,14 @@ def main(): elif request_event.type == RequestType.TransferVM: virtualmachine.transfer(request_event) else: - logging.info("VM Entry missing") - - logging.info("Running VMs %s", running_vms) + logger.info("VM Entry missing") + + logger.info("Running VMs %s", running_vms) if __name__ == "__main__": - main() - + argparser = argparse.ArgumentParser() + argparser.add_argument("hostname", help="Name of this host. e.g /v1/host/1") + args = argparser.parse_args() + mp.set_start_method('spawn') + main(args.hostname) diff --git a/host/qmp/__init__.py b/host/qmp/__init__.py index 97669bc..ba15838 100755 --- a/host/qmp/__init__.py +++ b/host/qmp/__init__.py @@ -15,24 +15,23 @@ import errno import logging import os -import subprocess -import re import shutil import socket +import subprocess import tempfile from . import qmp - LOG = logging.getLogger(__name__) # Mapping host architecture to any additional architectures it can # support which often includes its 32 bit cousin. ADDITIONAL_ARCHES = { - "x86_64" : "i386", - "aarch64" : "armhf" + "x86_64": "i386", + "aarch64": "armhf" } + def kvm_available(target_arch=None): host_arch = os.uname()[4] if target_arch and target_arch != host_arch: @@ -56,10 +55,12 @@ class QEMUMachineAddDeviceError(QEMUMachineError): failures reported by the QEMU binary itself. """ + class MonitorResponseError(qmp.QMPError): """ Represents erroneous QMP monitor reply """ + def __init__(self, reply): try: desc = reply["error"]["desc"] @@ -108,7 +109,7 @@ class QEMUMachine(object): self._qemu_log_file = None self._popen = None self._binary = binary - self._args = list(args) # Force copy args in case we modify them + self._args = list(args) # Force copy args in case we modify them self._wrapper = wrapper self._events = [] self._iolog = None @@ -453,10 +454,11 @@ class QEMUMachine(object): See event_match for details. timeout: QEMUMonitorProtocol.pull_event timeout parameter. """ + def _match(event): for name, match in events: if (event['event'] == name and - self.event_match(event, match)): + self.event_match(event, match)): return True return False diff --git a/host/qmp/__pycache__/__init__.cpython-37.pyc b/host/qmp/__pycache__/__init__.cpython-37.pyc deleted file mode 100755 index 3f11efc235ae2bfba83332b074d903719ace3747..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15086 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 Dl`yTW diff --git a/host/qmp/__pycache__/qmp.cpython-37.pyc b/host/qmp/__pycache__/qmp.cpython-37.pyc deleted file mode 100755 index e9f7c9443cd3bfc52fcc1b91eee75eab123a7230..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8571 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 diff --git a/host/qmp/qmp.py b/host/qmp/qmp.py index 5c8cf6a..bf35d71 100755 --- a/host/qmp/qmp.py +++ b/host/qmp/qmp.py @@ -8,10 +8,10 @@ # This work is licensed under the terms of the GNU GPL, version 2. See # the COPYING file in the top-level directory. -import json import errno -import socket +import json import logging +import socket class QMPError(Exception): @@ -31,7 +31,6 @@ class QMPTimeoutError(QMPError): class QEMUMonitorProtocol(object): - #: Logger object for debugging messages logger = logging.getLogger('QMP') #: Socket's error class diff --git a/host/virtualmachine.py b/host/virtualmachine.py index a989f3f..80e9846 100755 --- a/host/virtualmachine.py +++ b/host/virtualmachine.py @@ -6,30 +6,24 @@ import errno import os +import random import subprocess as sp import tempfile import time -import random -import ipaddress - from functools import wraps from os.path import join -from typing import Union from string import Template +from typing import Union import bitmath import sshtunnel -import qmp - -from decouple import config -from ucloud_common.helpers import get_ipv4_address -from ucloud_common.request import RequestEntry, RequestType -from ucloud_common.vm import VMEntry, VMStatus - -from config import (WITHOUT_CEPH, VM_PREFIX, VM_DIR, IMAGE_DIR, - NETWORK_PREFIX, etcd_client, logging, - request_pool, running_vms, vm_pool) +from common.helpers import get_ipv4_address +from common.request import RequestEntry, RequestType +from common.vm import VMEntry, VMStatus +from config import etcd_client, request_pool, running_vms, vm_pool, env_vars +from . import qmp +from host import logger class VM: def __init__(self, key, handle, vnc_socket_file): @@ -43,7 +37,7 @@ class VM: def create_dev(script, _id, dev, ip=None): command = [script, _id, dev] - if ip: + if ip: command.append(ip) try: output = sp.check_output(command, stderr=sp.PIPE) @@ -57,13 +51,13 @@ def create_dev(script, _id, dev, ip=None): def create_vxlan_br_tap(_id, _dev, ip=None): network_script_base = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'network') vxlan = create_dev(script=os.path.join(network_script_base, 'create-vxlan.sh'), - _id=_id, dev=_dev) + _id=_id, dev=_dev) if vxlan: bridge = create_dev(script=os.path.join(network_script_base, 'create-bridge.sh'), _id=_id, dev=vxlan, ip=ip) if bridge: tap = create_dev(script=os.path.join(network_script_base, 'create-tap.sh'), - _id=str(random.randint(1, 100000)), dev=bridge) + _id=str(random.randint(1, 100000)), dev=bridge) if tap: return tap @@ -77,35 +71,35 @@ def generate_mac(uaa=False, multicast=False, oui=None, separator=':', byte_fmt=' if oui: if type(oui) == str: oui = [int(chunk) for chunk in oui.split(separator)] - mac = oui + random_bytes(num=6-len(oui)) + mac = oui + random_bytes(num=6 - len(oui)) else: if multicast: - mac[0] |= 1 # set bit 0 + mac[0] |= 1 # set bit 0 else: - mac[0] &= ~1 # clear bit 0 + mac[0] &= ~1 # clear bit 0 if uaa: - mac[0] &= ~(1 << 1) # clear bit 1 + mac[0] &= ~(1 << 1) # clear bit 1 else: - mac[0] |= 1 << 1 # set bit 1 + mac[0] |= 1 << 1 # set bit 1 return separator.join(byte_fmt % b for b in mac) def update_radvd_conf(etcd_client): network_script_base = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'network') - networks = { - net.value['ipv6']:net.value['id'] - for net in etcd_client.get_prefix('/v1/network/', value_in_json=True) - if net.value.get('ipv6') - } + networks = { + net.value['ipv6']: net.value['id'] + for net in etcd_client.get_prefix('/v1/network/', value_in_json=True) + if net.value.get('ipv6') + } radvd_template = open(os.path.join(network_script_base, 'radvd-template.conf'), 'r').read() radvd_template = Template(radvd_template) content = [radvd_template.safe_substitute(bridge='br{}'.format(networks[net]), - prefix=net) + prefix=net) for net in networks if networks.get(net)] - + with open('/etc/radvd.conf', 'w') as radvd_conf: radvd_conf.writelines(content) @@ -113,7 +107,7 @@ def update_radvd_conf(etcd_client): def get_start_command_args( - vm_entry, vnc_sock_filename: str, migration=False, migration_port=4444, + vm_entry, vnc_sock_filename: str, migration=False, migration_port=4444, ): threads_per_core = 1 vm_memory = int(bitmath.parse_string(vm_entry.specs["ram"]).to_MB()) @@ -121,9 +115,9 @@ def get_start_command_args( vm_uuid = vm_entry.uuid vm_networks = vm_entry.network - if WITHOUT_CEPH: + if env_vars.get('WITHOUT_CEPH'): command = "-drive file={},format=raw,if=virtio,cache=none".format( - os.path.join(VM_DIR, vm_uuid) + os.path.join(env_vars.get('VM_DIR'), vm_uuid) ) else: command = "-drive file=rbd:uservms/{},format=raw,if=virtio,cache=none".format( @@ -138,24 +132,24 @@ def get_start_command_args( if migration: command += " -incoming tcp:0:{}".format(migration_port) - + tap = None for network_and_mac in vm_networks: network_name, mac = network_and_mac - - _key = os.path.join(NETWORK_PREFIX, vm_entry.owner, network_name) + + _key = os.path.join(env_vars.get('NETWORK_PREFIX'), vm_entry.owner, network_name) network = etcd_client.get(_key, value_in_json=True) network_type = network.value["type"] network_id = str(network.value["id"]) network_ipv6 = network.value["ipv6"] if network_type == "vxlan": - tap = create_vxlan_br_tap(network_id, config("VXLAN_PHY_DEV"), network_ipv6) + tap = create_vxlan_br_tap(network_id, env_vars.get("VXLAN_PHY_DEV"), network_ipv6) update_radvd_conf(etcd_client) - - command += " -netdev tap,id=vmnet{net_id},ifname={tap},script=no,downscript=no"\ - " -device virtio-net-pci,netdev=vmnet{net_id},mac={mac}"\ - .format(tap=tap, net_id=network_id, mac=mac) + + command += " -netdev tap,id=vmnet{net_id},ifname={tap},script=no,downscript=no" \ + " -device virtio-net-pci,netdev=vmnet{net_id},mac={mac}" \ + .format(tap=tap, net_id=network_id, mac=mac) return command.split(" ") @@ -189,15 +183,15 @@ def need_running_vm(func): if vm: try: status = vm.handle.command("query-status") - logging.debug("VM Status Check - %s", status) + logger.debug("VM Status Check - %s", status) except Exception as exception: - logging.info("%s failed - VM %s %s", func.__name__, e, exception) + logger.info("%s failed - VM %s %s", func.__name__, e, exception) else: return func(e) return None else: - logging.info("%s failed because VM %s is not running", func.__name__, e.key) + logger.info("%s failed because VM %s is not running", func.__name__, e.key) return None return wrapper @@ -206,18 +200,18 @@ def need_running_vm(func): def create(vm_entry: VMEntry): vm_hdd = int(bitmath.parse_string(vm_entry.specs["os-ssd"]).to_MB()) - if WITHOUT_CEPH: + if env_vars.get('WITHOUT_CEPH'): _command_to_create = [ "cp", - os.path.join(IMAGE_DIR, vm_entry.image_uuid), - os.path.join(VM_DIR, vm_entry.uuid), + os.path.join(env_vars.get('IMAGE_DIR'), vm_entry.image_uuid), + os.path.join(env_vars.get('VM_DIR'), vm_entry.uuid), ] _command_to_extend = [ "qemu-img", "resize", "-f", "raw", - os.path.join(VM_DIR, vm_entry.uuid), + os.path.join(env_vars.get('VM_DIR'), vm_entry.uuid), "{}M".format(vm_hdd), ] else: @@ -240,22 +234,22 @@ def create(vm_entry: VMEntry): sp.check_output(_command_to_create) except sp.CalledProcessError as e: if e.returncode == errno.EEXIST: - logging.debug("Image for vm %s exists", vm_entry.uuid) + logger.debug("Image for vm %s exists", vm_entry.uuid) # File Already exists. No Problem Continue return # This exception catches all other exceptions # i.e FileNotFound (BaseImage), pool Does Not Exists etc. - logging.exception(e) + logger.exception(e) vm_entry.status = "ERROR" else: try: sp.check_output(_command_to_extend) except Exception as e: - logging.exception(e) + logger.exception(e) else: - logging.info("New VM Created") + logger.info("New VM Created") def start(vm_entry: VMEntry): @@ -263,7 +257,7 @@ def start(vm_entry: VMEntry): # VM already running. No need to proceed further. if _vm: - logging.info("VM %s already running", vm_entry.uuid) + logger.info("VM %s already running", vm_entry.uuid) return else: create(vm_entry) @@ -282,19 +276,19 @@ def stop(vm_entry): def delete(vm_entry): - logging.info("Deleting VM | %s", vm_entry) + logger.info("Deleting VM | %s", vm_entry) stop(vm_entry) - path_without_protocol = vm_entry.path[vm_entry.path.find(":") + 1 :] + path_without_protocol = vm_entry.path[vm_entry.path.find(":") + 1:] - if WITHOUT_CEPH: - vm_deletion_command = ["rm", os.path.join(VM_DIR, vm_entry.uuid)] + if env_vars.get('WITHOUT_CEPH'): + vm_deletion_command = ["rm", os.path.join(env_vars.get('VM_DIR'), vm_entry.uuid)] else: vm_deletion_command = ["rbd", "rm", path_without_protocol] try: sp.check_output(vm_deletion_command) except Exception as e: - logging.exception(e) + logger.exception(e) else: etcd_client.client.delete(vm_entry.key) @@ -307,20 +301,20 @@ def transfer(request_event): _host, _port = request_event.parameters["host"], request_event.parameters["port"] _uuid = request_event.uuid _destination = request_event.destination_host_key - vm = get_vm(running_vms, join(VM_PREFIX, _uuid)) + vm = get_vm(running_vms, join(env_vars.get('VM_PREFIX'), _uuid)) if vm: tunnel = sshtunnel.SSHTunnelForwarder( (_host, 22), - ssh_username=config("ssh_username"), - ssh_pkey=config("ssh_pkey"), - ssh_private_key_password=config("ssh_private_key_password"), + ssh_username=env_vars.get("ssh_username"), + ssh_pkey=env_vars.get("ssh_pkey"), + ssh_private_key_password=env_vars.get("ssh_private_key_password"), remote_bind_address=("127.0.0.1", _port), ) try: tunnel.start() except sshtunnel.BaseSSHTunnelForwarderError: - logging.exception("Couldn't establish connection to (%s, 22)", _host) + logger.exception("Couldn't establish connection to (%s, 22)", _host) else: vm.handle.command( "migrate", uri="tcp:{}:{}".format(_host, tunnel.local_bind_port) @@ -356,7 +350,7 @@ def init_migration(vm_entry, destination_host_key): if _vm: # VM already running. No need to proceed further. - logging.info("%s Already running", _vm.key) + logger.info("%s Already running", _vm.key) return launch_vm(vm_entry, migration=True, migration_port=4444, @@ -364,13 +358,13 @@ def init_migration(vm_entry, destination_host_key): def launch_vm(vm_entry, migration=False, migration_port=None, destination_host_key=None): - logging.info("Starting %s", vm_entry.key) + logger.info("Starting %s", vm_entry.key) vm = create_vm_object(vm_entry, migration=migration, migration_port=migration_port) try: vm.handle.launch() except Exception as e: - logging.exception(e) + logger.exception(e) if migration: # We don't care whether MachineError or any other error occurred @@ -392,6 +386,7 @@ def launch_vm(vm_entry, migration=False, migration_port=None, destination_host_k parameters={"host": get_ipv4_address(), "port": 4444}, uuid=vm_entry.uuid, destination_host_key=destination_host_key, + request_prefix=env_vars.get("REQUEST_PREFIX") ) request_pool.put(r) else: @@ -400,4 +395,3 @@ def launch_vm(vm_entry, migration=False, migration_port=None, destination_host_k vm_entry.add_log("Started successfully") vm_pool.put(vm_entry) - \ No newline at end of file diff --git a/imagescanner/__init__.py b/imagescanner/__init__.py new file mode 100644 index 0000000..eea436a --- /dev/null +++ b/imagescanner/__init__.py @@ -0,0 +1,3 @@ +import logging + +logger = logging.getLogger(__name__) diff --git a/imagescanner/config.py b/imagescanner/config.py deleted file mode 100755 index 3ccc06b..0000000 --- a/imagescanner/config.py +++ /dev/null @@ -1,22 +0,0 @@ -import logging - -from etcd3_wrapper import Etcd3Wrapper -from decouple import config - -BASE_PATH = config("BASE_DIR", "/var/www") -WITHOUT_CEPH = config("WITHOUT_CEPH", False, cast=bool) -ETCD_URL = config("ETCD_URL") -IMAGE_PREFIX = config("IMAGE_PREFIX") -IMAGE_STORE_PREFIX = config("IMAGE_STORE_PREFIX") -IMAGE_DIR = config("IMAGE_DIR") - -logging.basicConfig( - level=logging.DEBUG, - filename="log.txt", - filemode="a", - format="%(asctime)s: %(levelname)s - %(message)s", - datefmt="%d-%b-%y %H:%M:%S", -) - - -client = Etcd3Wrapper(host=ETCD_URL) diff --git a/imagescanner/main.py b/imagescanner/main.py index 146e756..97da589 100755 --- a/imagescanner/main.py +++ b/imagescanner/main.py @@ -1,11 +1,10 @@ -import os import json +import os import subprocess import sys -from config import (logging, client, IMAGE_DIR, - BASE_PATH, WITHOUT_CEPH, - IMAGE_PREFIX, IMAGE_STORE_PREFIX) +from config import etcd_client, env_vars +from imagescanner import logger def qemu_img_type(path): @@ -13,7 +12,7 @@ def qemu_img_type(path): try: qemu_img_info = subprocess.check_output(qemu_img_info_command) except Exception as e: - logging.exception(e) + logger.exception(e) return None else: qemu_img_info = json.loads(qemu_img_info.decode("utf-8")) @@ -21,12 +20,12 @@ def qemu_img_type(path): def main(): - # If you are using WITHOUT_CEPH FLAG in .env - # then please make sure that IMAGE_DIR directory + # If you are using env_vars.get('WITHOUT_CEPH') FLAG in .env + # then please make sure that env_vars.get('IMAGE_DIR') directory # exists otherwise this script would fail - if WITHOUT_CEPH and not os.path.isdir(IMAGE_DIR): - print("You have set WITHOUT_CEPH to True. So," - "the {} must exists. But, it don't".format(IMAGE_DIR)) + if env_vars.get('WITHOUT_CEPH') and not os.path.isdir(env_vars.get('IMAGE_DIR')): + print("You have set env_vars.get('WITHOUT_CEPH') to True. So," + "the {} must exists. But, it don't".format(env_vars.get('IMAGE_DIR'))) sys.exit(1) try: @@ -36,7 +35,7 @@ def main(): sys.exit(1) # We want to get images entries that requests images to be created - images = client.get_prefix(IMAGE_PREFIX, value_in_json=True) + images = etcd_client.get_prefix(env_vars.get('IMAGE_PREFIX'), value_in_json=True) images_to_be_created = list(filter(lambda im: im.value['status'] == 'TO_BE_CREATED', images)) for image in images_to_be_created: @@ -45,9 +44,9 @@ def main(): image_owner = image.value['owner'] image_filename = image.value['filename'] image_store_name = image.value['store_name'] - image_full_path = os.path.join(BASE_PATH, image_owner, image_filename) + image_full_path = os.path.join(env_vars.get('BASE_DIR'), image_owner, image_filename) - image_stores = client.get_prefix(IMAGE_STORE_PREFIX, value_in_json=True) + image_stores = etcd_client.get_prefix(env_vars.get('IMAGE_STORE_PREFIX'), value_in_json=True) user_image_store = next(filter( lambda s, store_name=image_store_name: s.value["name"] == store_name, image_stores @@ -56,27 +55,25 @@ def main(): image_store_pool = user_image_store.value['attributes']['pool'] except Exception as e: - logging.exception(e) + logger.exception(e) else: # At least our basic data is available qemu_img_convert_command = ["qemu-img", "convert", "-f", "qcow2", "-O", "raw", image_full_path, "image.raw"] - - if WITHOUT_CEPH: - image_import_command = ["mv", "image.raw", os.path.join(IMAGE_DIR, image_uuid)] + if env_vars.get('WITHOUT_CEPH'): + image_import_command = ["mv", "image.raw", os.path.join(env_vars.get('IMAGE_DIR'), image_uuid)] snapshot_creation_command = ["true"] snapshot_protect_command = ["true"] else: image_import_command = ["rbd", "import", "image.raw", "{}/{}".format(image_store_pool, image_uuid)] snapshot_creation_command = ["rbd", "snap", "create", - "{}/{}@protected".format(image_store_pool, image_uuid)] + "{}/{}@protected".format(image_store_pool, image_uuid)] snapshot_protect_command = ["rbd", "snap", "protect", "{}/{}@protected".format(image_store_pool, image_uuid)] - # First check whether the image is qcow2 if qemu_img_type(image_full_path) == "qcow2": @@ -90,19 +87,18 @@ def main(): # Create and Protect Snapshot subprocess.check_output(snapshot_creation_command) subprocess.check_output(snapshot_protect_command) - + except Exception as e: - logging.exception(e) - + logger.exception(e) + else: # Everything is successfully done image.value["status"] = "CREATED" - client.put(image.key, json.dumps(image.value)) + etcd_client.put(image.key, json.dumps(image.value)) else: # The user provided image is either not found or of invalid format image.value["status"] = "INVALID_IMAGE" - client.put(image.key, json.dumps(image.value)) - + etcd_client.put(image.key, json.dumps(image.value)) try: os.remove("image.raw") @@ -111,4 +107,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/metadata/__init__.py b/metadata/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/metadata/config.py b/metadata/config.py deleted file mode 100644 index 05cc113..0000000 --- a/metadata/config.py +++ /dev/null @@ -1,22 +0,0 @@ -import logging - -from etcd3_wrapper import Etcd3Wrapper -from decouple import config - -from ucloud_common.vm import VmPool - -logging.basicConfig( - level=logging.DEBUG, - filename="log.txt", - filemode="a", - format="%(asctime)s: %(levelname)s - %(message)s", - datefmt="%d-%b-%y %H:%M:%S", -) - - -VM_PREFIX = config("VM_PREFIX") -USER_PREFIX = config("USER_PREFIX") - -etcd_client = Etcd3Wrapper(host=config("ETCD_URL")) - -VM_POOL = VmPool(etcd_client, VM_PREFIX) diff --git a/metadata/main.py b/metadata/main.py index 22a4e62..7176d41 100644 --- a/metadata/main.py +++ b/metadata/main.py @@ -2,14 +2,15 @@ import os from flask import Flask, request from flask_restful import Resource, Api -from config import etcd_client, VM_POOL, USER_PREFIX + +from config import etcd_client, env_vars, vm_pool app = Flask(__name__) api = Api(app) def get_vm_entry(mac_addr): - return next(filter(lambda vm: mac_addr in list(zip(*vm.network))[1], VM_POOL.vms), None) + return next(filter(lambda vm: mac_addr in list(zip(*vm.network))[1], vm_pool.vms), None) # https://stackoverflow.com/questions/37140846/how-to-convert-ipv6-link-local-address-to-mac-address-in-python @@ -43,8 +44,8 @@ class Root(Resource): return {'message': 'Metadata for such VM does not exists.'}, 404 else: - # {user_prefix}/{realm}/{name}/key - etcd_key = os.path.join(USER_PREFIX, data.value['owner_realm'], + # {env_vars.get('USER_PREFIX')}/{realm}/{name}/key + etcd_key = os.path.join(env_vars.get('USER_PREFIX'), data.value['owner_realm'], data.value['owner'], 'key') etcd_entry = etcd_client.get_prefix(etcd_key, value_in_json=True) user_personal_ssh_keys = [key.value for key in etcd_entry] @@ -81,5 +82,10 @@ class Root(Resource): api.add_resource(Root, '/') -if __name__ == '__main__': + +def main(): app.run(debug=True, host="::", port="80") + + +if __name__ == '__main__': + main() diff --git a/network/__init__.py b/network/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scheduler/__init__.py b/scheduler/__init__.py new file mode 100644 index 0000000..95e1be0 --- /dev/null +++ b/scheduler/__init__.py @@ -0,0 +1,3 @@ +import logging + +logger = logging.getLogger(__name__) \ No newline at end of file diff --git a/scheduler/config.py b/scheduler/config.py deleted file mode 100755 index 81e8503..0000000 --- a/scheduler/config.py +++ /dev/null @@ -1,25 +0,0 @@ -import logging - -from decouple import config -from etcd3_wrapper import Etcd3Wrapper -from ucloud_common.vm import VmPool -from ucloud_common.host import HostPool -from ucloud_common.request import RequestPool - -logging.basicConfig( - level=logging.DEBUG, - filename="log.txt", - filemode="a", - format="%(asctime)s: %(levelname)s - %(message)s", - datefmt="%d-%b-%y %H:%M:%S", -) - -vm_prefix = config("VM_PREFIX") -host_prefix = config("HOST_PREFIX") -request_prefix = config("REQUEST_PREFIX") - -etcd_client = Etcd3Wrapper(host=config("ETCD_URL")) - -vm_pool = VmPool(etcd_client, vm_prefix) -host_pool = HostPool(etcd_client, host_prefix) -request_pool = RequestPool(etcd_client, request_prefix) \ No newline at end of file diff --git a/scheduler/helper.py b/scheduler/helper.py index 577bc91..81b5869 100755 --- a/scheduler/helper.py +++ b/scheduler/helper.py @@ -1,21 +1,15 @@ -import bitmath - from collections import Counter from functools import reduce -from ucloud_common.vm import VmPool, VMStatus -from ucloud_common.host import HostPool, HostStatus -from ucloud_common.request import RequestEntry, RequestPool, RequestType +import bitmath -from decouple import config -from config import etcd_client as client - -vm_pool = VmPool(client, config("VM_PREFIX")) -host_pool = HostPool(client, config("HOST_PREFIX")) -request_pool = RequestPool(client, config("REQUEST_PREFIX")) +from common.host import HostStatus +from common.request import RequestEntry, RequestType +from common.vm import VMStatus +from config import vm_pool, host_pool, request_pool, env_vars -def accumulated_specs(vms_specs): +def accumulated_specs(vms_specs): if not vms_specs: return {} return reduce((lambda x, y: Counter(x) + Counter(y)), vms_specs) @@ -23,7 +17,7 @@ def accumulated_specs(vms_specs): def remaining_resources(host_specs, vms_specs): # Return remaining resources host_specs - vms - + _vms_specs = Counter(vms_specs) _remaining = Counter(host_specs) @@ -69,7 +63,7 @@ def get_suitable_host(vm_specs, hosts=None): # Find out remaining resources after # host_specs - already running vm_specs remaining = remaining_resources(host.specs, running_vms_accumulated_specs) - + # Find out remaining - new_vm_specs remaining = remaining_resources(remaining, vm_specs) @@ -111,7 +105,8 @@ def assign_host(vm): r = RequestEntry.from_scratch(type=RequestType.StartVM, uuid=vm.uuid, - hostname=vm.hostname) + hostname=vm.hostname, + request_prefix=env_vars.get("REQUEST_PREFIX")) request_pool.put(r) vm.log.append("VM scheduled for starting") diff --git a/scheduler/main.py b/scheduler/main.py index 3fb0d1b..507ac44 100755 --- a/scheduler/main.py +++ b/scheduler/main.py @@ -4,28 +4,26 @@ # 2. Introduce a status endpoint of the scheduler - # maybe expose a prometheus compatible output -import logging - -from ucloud_common.request import RequestEntry, RequestType - -from config import etcd_client as client -from config import (host_pool, request_pool, vm_pool, request_prefix) -from helper import (get_suitable_host, dead_host_mitigation, dead_host_detection, - assign_host, NoSuitableHostFound) +from common.request import RequestEntry, RequestType +from config import etcd_client +from config import host_pool, request_pool, vm_pool, env_vars +from .helper import (get_suitable_host, dead_host_mitigation, dead_host_detection, + assign_host, NoSuitableHostFound) +from scheduler import logger def main(): - logging.info("%s SESSION STARTED %s", '*' * 5, '*' * 5) + logger.info("%s SESSION STARTED %s", '*' * 5, '*' * 5) pending_vms = [] for request_iterator in [ - client.get_prefix(request_prefix, value_in_json=True), - client.watch_prefix(request_prefix, timeout=5, value_in_json=True), + etcd_client.get_prefix(env_vars.get('REQUEST_PREFIX'), value_in_json=True), + etcd_client.watch_prefix(env_vars.get('REQUEST_PREFIX'), timeout=5, value_in_json=True), ]: for request_event in request_iterator: request_entry = RequestEntry(request_event) - logging.debug("%s, %s", request_entry.key, request_entry.value) + logger.debug("%s, %s", request_entry.key, request_entry.value) # Never Run time critical mechanism inside timeout # mechanism because timeout mechanism only comes @@ -35,9 +33,9 @@ def main(): # Detect hosts that are dead and set their status # to "DEAD", and their VMs' status to "KILLED" - logging.debug("TIMEOUT event occured") + logger.debug("TIMEOUT event occured") dead_hosts = dead_host_detection() - logging.debug("Dead hosts: %s", dead_hosts) + logger.debug("Dead hosts: %s", dead_hosts) dead_host_mitigation(dead_hosts) # If there are VMs that weren't assigned a host @@ -49,15 +47,16 @@ def main(): pending_vm_entry = pending_vms.pop() r = RequestEntry.from_scratch(type="ScheduleVM", uuid=pending_vm_entry.uuid, - hostname=pending_vm_entry.hostname) + hostname=pending_vm_entry.hostname, + request_prefix=env_vars.get("REQUEST_PREFIX")) request_pool.put(r) elif request_entry.type == RequestType.ScheduleVM: vm_entry = vm_pool.get(request_entry.uuid) if vm_entry is None: - logging.info("Trying to act on {} but it is deleted".format(request_entry.uuid)) + logger.info("Trying to act on {} but it is deleted".format(request_entry.uuid)) continue - client.client.delete(request_entry.key) # consume Request + etcd_client.client.delete(request_entry.key) # consume Request # If the Request is about a VM which is labelled as "migration" # and has a destination @@ -67,12 +66,13 @@ def main(): get_suitable_host(vm_specs=vm_entry.specs, hosts=[host_pool.get(request_entry.destination)]) except NoSuitableHostFound: - logging.info("Requested destination host doesn't have enough capacity" + logger.info("Requested destination host doesn't have enough capacity" "to hold %s", vm_entry.uuid) else: r = RequestEntry.from_scratch(type=RequestType.InitVMMigration, uuid=request_entry.uuid, - destination=request_entry.destination) + destination=request_entry.destination, + request_prefix=env_vars.get("REQUEST_PREFIX")) request_pool.put(r) # If the Request is about a VM that just want to get started/created @@ -86,7 +86,7 @@ def main(): vm_pool.put(vm_entry) pending_vms.append(vm_entry) - logging.info("No Resource Left. Emailing admin....") + logger.info("No Resource Left. Emailing admin....") if __name__ == "__main__": diff --git a/scheduler/tests/__init__.py b/scheduler/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scheduler/tests/test_basics.py b/scheduler/tests/test_basics.py index 227e84b..ef82fc0 100755 --- a/scheduler/tests/test_basics.py +++ b/scheduler/tests/test_basics.py @@ -1,13 +1,10 @@ -import unittest -import sys import json import multiprocessing -import time - +import sys +import unittest from datetime import datetime from os.path import dirname - BASE_DIR = dirname(dirname(__file__)) sys.path.insert(0, BASE_DIR) @@ -15,13 +12,12 @@ from main import ( accumulated_specs, remaining_resources, VmPool, - dead_host_detection, - dead_host_mitigation, main, ) from config import etcd_client + class TestFunctions(unittest.TestCase): @classmethod def setUpClass(cls): diff --git a/scheduler/tests/test_dead_host_mechanism.py b/scheduler/tests/test_dead_host_mechanism.py index 33bed23..0b403ef 100755 --- a/scheduler/tests/test_dead_host_mechanism.py +++ b/scheduler/tests/test_dead_host_mechanism.py @@ -1,24 +1,18 @@ -import unittest import sys -import json -import multiprocessing -import time - +import unittest from datetime import datetime from os.path import dirname + BASE_DIR = dirname(dirname(__file__)) sys.path.insert(0, BASE_DIR) from main import ( - accumulated_specs, - remaining_resources, - VmPool, dead_host_detection, dead_host_mitigation, - main, config ) + class TestDeadHostMechanism(unittest.TestCase): def setUp(self): self.client = config.etcd_client diff --git a/ucloud.py b/ucloud.py index 5886502..8774fa3 100644 --- a/ucloud.py +++ b/ucloud.py @@ -1,16 +1,50 @@ import argparse -import subprocess as sp -arg_parser = argparse.ArgumentParser(prog='ucloud', - description='Open Source Cloud Management Software') -arg_parser.add_argument('component', - choices=['api', 'scheduler', 'host', - 'filescanner','imagescanner', - 'metadata']) -arg_parser.add_argument('component_args', nargs='*') -args = arg_parser.parse_args() +import multiprocessing as mp +import logging -try: - command = ['pipenv', 'run', 'python', 'main.py', *args.component_args] - sp.run(command, cwd=args.component) -except Exception as error: - print(error) +from os.path import join as join_path + +if __name__ == "__main__": + arg_parser = argparse.ArgumentParser(prog='ucloud', + description='Open Source Cloud Management Software') + arg_parser.add_argument('component', + choices=['api', 'scheduler', 'host', + 'filescanner', 'imagescanner', + 'metadata']) + arg_parser.add_argument('component_args', nargs='*') + args = arg_parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG, + filename=join_path("logs.txt"), + filemode="a", + format="%(name)s %(asctime)s: %(levelname)s - %(message)s", + datefmt="%d-%b-%y %H:%M:%S", + ) + + if args.component == 'api': + from api.main import main + + main() + elif args.component == 'host': + from host.main import main + + hostname = args.component_args + mp.set_start_method('spawn') + main(*hostname) + elif args.component == 'scheduler': + from scheduler.main import main + + main() + elif args.component == 'filescanner': + from filescanner.main import main + + main() + elif args.component == 'imagescanner': + from imagescanner.main import main + + main() + elif args.component == 'metadata': + from metadata.main import main + + main() From cc0ca68498aba73f023a8c07855418eff16fab63 Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 25 Nov 2019 11:52:36 +0500 Subject: [PATCH 015/284] * Refactoring * Fix issue that causes a new image store to be created at every start of ucloud-api. * VM Migration API call now takes hostname instead of host key. * StorageHandler Classes are introduced. They transparently handles things related to importing of image, make vm out of image, resize vm image, delete vm image etc. * Loggers added to __init__.py of every ucloud component's subpackage. * Non-Trivial Timeout Events are no longer logged. * Fix issue that prevents removal of stopped VMs (i.e VMs that are successfully migrated). * Improved unit handling added. e.g MB, Mb, mB, mb are all Mega Bytes. * VM migration is now possible on IPv6 host. * Destination VM (receiving side of migration of a vm) now correctly expects incoming data on free ephemeral port. * Traceback is no longer output to screen, instead it goes to log file. * All sanity checks are put into a single file. These checks are run by ucloud.py before running any of ucloud component. --- api/helper.py | 2 +- api/main.py | 99 ++-- api/schemas.py | 6 +- common/classes.py | 22 - common/helpers.py | 15 + common/storage_handlers.py | 158 ++++++ common/vm.py | 4 - config.py | 17 +- docs/source/diagram-code/ucloud | 44 ++ docs/source/images/ucloud.svg | 494 ++++++++++++++++++ docs/source/index.rst | 8 +- docs/source/introduction/installation.rst | 34 +- docs/source/misc/todo.rst | 12 + docs/source/theory/summary.rst | 98 ++++ .../installation-troubleshooting.rst | 24 + filescanner/__init__.py | 3 + filescanner/main.py | 9 +- host/helper.py | 13 + host/main.py | 25 +- host/qmp/__init__.py | 1 + host/virtualmachine.py | 151 ++---- imagescanner/main.py | 56 +- sanity_checks.py | 33 ++ scheduler/helper.py | 8 +- scheduler/main.py | 12 +- ucloud.py | 47 +- 26 files changed, 1101 insertions(+), 294 deletions(-) create mode 100644 common/storage_handlers.py create mode 100644 docs/source/diagram-code/ucloud create mode 100644 docs/source/images/ucloud.svg create mode 100644 docs/source/theory/summary.rst create mode 100644 docs/source/troubleshooting/installation-troubleshooting.rst create mode 100644 host/helper.py create mode 100644 sanity_checks.py diff --git a/api/helper.py b/api/helper.py index a45bd16..eb32373 100755 --- a/api/helper.py +++ b/api/helper.py @@ -22,7 +22,7 @@ def check_otp(name, realm, token): except binascii.Error: return 400 - response = requests.get( + response = requests.post( "{OTP_SERVER}{OTP_VERIFY_ENDPOINT}".format( OTP_SERVER=env_vars.get("OTP_SERVER", ""), OTP_VERIFY_ENDPOINT=env_vars.get("OTP_VERIFY_ENDPOINT", "verify"), diff --git a/api/main.py b/api/main.py index e621ce1..59b7dc0 100644 --- a/api/main.py +++ b/api/main.py @@ -1,16 +1,16 @@ import json -import os import subprocess -from uuid import uuid4 - import pynetbox + +from uuid import uuid4 +from os.path import join as join_path + from flask import Flask, request from flask_restful import Resource, Api from common import counters from common.request import RequestEntry, RequestType -from common.vm import VMStatus -from config import (etcd_client, request_pool, vm_pool, host_pool, env_vars) +from config import (etcd_client, request_pool, vm_pool, host_pool, env_vars, image_storage_handler) from . import schemas from .helper import generate_mac, mac2ipv6 from api import logger @@ -20,13 +20,15 @@ api = Api(app) class CreateVM(Resource): + """API Request to Handle Creation of VM""" + @staticmethod def post(): data = request.json validator = schemas.CreateVMSchema(data) if validator.is_valid(): vm_uuid = uuid4().hex - vm_key = os.path.join(env_vars.get("VM_PREFIX"), vm_uuid) + vm_key = join_path(env_vars.get("VM_PREFIX"), vm_uuid) specs = { "cpu": validator.specs["cpu"], "ram": validator.specs["ram"], @@ -67,14 +69,14 @@ class VmStatus(Resource): validator = schemas.VMStatusSchema(data) if validator.is_valid(): vm = vm_pool.get( - os.path.join(env_vars.get("VM_PREFIX"), data["uuid"]) + join_path(env_vars.get("VM_PREFIX"), data["uuid"]) ) vm_value = vm.value.copy() vm_value["ip"] = [] for network_and_mac in vm.network: network_name, mac = network_and_mac network = etcd_client.get( - os.path.join( + join_path( env_vars.get("NETWORK_PREFIX"), data["name"], network_name, @@ -96,7 +98,7 @@ class CreateImage(Resource): validator = schemas.CreateImageSchema(data) if validator.is_valid(): file_entry = etcd_client.get( - os.path.join(env_vars.get("FILE_PREFIX"), data["uuid"]) + join_path(env_vars.get("FILE_PREFIX"), data["uuid"]) ) file_entry_value = json.loads(file_entry.value) @@ -109,7 +111,7 @@ class CreateImage(Resource): "visibility": "public", } etcd_client.put( - os.path.join(env_vars.get("IMAGE_PREFIX"), data["uuid"]), + join_path(env_vars.get("IMAGE_PREFIX"), data["uuid"]), json.dumps(image_entry_json), ) @@ -123,8 +125,9 @@ class ListPublicImages(Resource): images = etcd_client.get_prefix( env_vars.get("IMAGE_PREFIX"), value_in_json=True ) - r = {} - r["images"] = [] + r = { + "images": [] + } for image in images: image_key = "{}:{}".format( image.value["store_name"], image.value["name"] @@ -143,46 +146,22 @@ class VMAction(Resource): if validator.is_valid(): vm_entry = vm_pool.get( - os.path.join(env_vars.get("VM_PREFIX"), data["uuid"]) + join_path(env_vars.get("VM_PREFIX"), data["uuid"]) ) action = data["action"] if action == "start": - vm_entry.status = VMStatus.requested_start - vm_pool.put(vm_entry) action = "schedule" if action == "delete" and vm_entry.hostname == "": - try: - path_without_protocol = vm_entry.path[ - vm_entry.path.find(":") + 1: - ] - - if env_vars.get("WITHOUT_CEPH"): - command_to_delete = [ - "rm", - "-rf", - os.path.join("/var/vm", vm_entry.uuid), - ] - else: - command_to_delete = [ - "rbd", - "rm", - path_without_protocol, - ] - - subprocess.check_output( - command_to_delete, stderr=subprocess.PIPE - ) - except subprocess.CalledProcessError as e: - if "No such file" in e.stderr.decode("utf-8"): + if image_storage_handler.is_vm_image_exists(vm_entry.uuid): + r_status = image_storage_handler.delete_vm_image(vm_entry.uuid) + if r_status: etcd_client.client.delete(vm_entry.key) return {"message": "VM successfully deleted"} else: - logger.exception(e) - return { - "message": "Some error occurred while deleting VM" - } + logger.error("Some Error Occurred while deleting VM") + return {"message": "VM deletion unsuccessfull"} else: etcd_client.client.delete(vm_entry.key) return {"message": "VM successfully deleted"} @@ -211,8 +190,8 @@ class VMMigration(Resource): r = RequestEntry.from_scratch( type=RequestType.ScheduleVM, uuid=vm.uuid, - destination=os.path.join( - env_vars.get("HOST_PREFIX"), data["destination"] + destination=join_path( + env_vars.get("HOST_PREFIX"), validator.destination.value ), migration=True, request_prefix=env_vars.get("REQUEST_PREFIX") @@ -289,7 +268,7 @@ class CreateHost(Resource): data = request.json validator = schemas.CreateHostSchema(data) if validator.is_valid(): - host_key = os.path.join(env_vars.get("HOST_PREFIX"), uuid4().hex) + host_key = join_path(env_vars.get("HOST_PREFIX"), uuid4().hex) host_entry = { "specs": data["specs"], "hostname": data["hostname"], @@ -327,7 +306,7 @@ class GetSSHKeys(Resource): if not validator.key_name.value: # {user_prefix}/{realm}/{name}/key/ - etcd_key = os.path.join( + etcd_key = join_path( env_vars.get('USER_PREFIX'), data["realm"], data["name"], @@ -344,7 +323,7 @@ class GetSSHKeys(Resource): else: # {user_prefix}/{realm}/{name}/key/{key_name} - etcd_key = os.path.join( + etcd_key = join_path( env_vars.get('USER_PREFIX'), data["realm"], data["name"], @@ -373,7 +352,7 @@ class AddSSHKey(Resource): if validator.is_valid(): # {user_prefix}/{realm}/{name}/key/{key_name} - etcd_key = os.path.join( + etcd_key = join_path( env_vars.get("USER_PREFIX"), data["realm"], data["name"], @@ -403,7 +382,7 @@ class RemoveSSHKey(Resource): if validator.is_valid(): # {user_prefix}/{realm}/{name}/key/{key_name} - etcd_key = os.path.join( + etcd_key = join_path( env_vars.get("USER_PREFIX"), data["realm"], data["name"], @@ -462,7 +441,7 @@ class CreateNetwork(Resource): else: network_entry["ipv6"] = "fd00::/64" - network_key = os.path.join( + network_key = join_path( env_vars.get("NETWORK_PREFIX"), data["name"], data["network_name"], @@ -480,7 +459,7 @@ class ListUserNetwork(Resource): validator = schemas.OTPSchema(data) if validator.is_valid(): - prefix = os.path.join( + prefix = join_path( env_vars.get("NETWORK_PREFIX"), data["name"] ) networks = etcd_client.get_prefix(prefix, value_in_json=True) @@ -517,15 +496,17 @@ api.add_resource(CreateNetwork, "/network/create") def main(): - data = { - "is_public": True, - "type": "ceph", - "name": "images", - "description": "first ever public image-store", - "attributes": {"list": [], "key": [], "pool": "images"}, - } + image_stores = list(etcd_client.get_prefix(env_vars.get('IMAGE_STORE_PREFIX'), value_in_json=True)) + if len(image_stores) == 0: + data = { + "is_public": True, + "type": "ceph", + "name": "images", + "description": "first ever public image-store", + "attributes": {"list": [], "key": [], "pool": "images"}, + } - etcd_client.put(os.path.join(env_vars.get('IMAGE_STORE_PREFIX'), uuid4().hex), json.dumps(data)) + etcd_client.put(join_path(env_vars.get('IMAGE_STORE_PREFIX'), uuid4().hex), json.dumps(data)) app.run(host="::", debug=True) diff --git a/api/schemas.py b/api/schemas.py index 28a1bc1..e50d9f0 100755 --- a/api/schemas.py +++ b/api/schemas.py @@ -381,12 +381,14 @@ class VmMigrationSchema(OTPSchema): super().__init__(data=data, fields=fields) def destination_validation(self): - host_key = self.destination.value - host = host_pool.get(host_key) + hostname = self.destination.value + host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) if not host: self.add_error("No Such Host ({}) exists".format(self.destination.value)) elif host.status != HostStatus.alive: self.add_error("Destination Host is dead") + else: + self.destination.value = host.key def validation(self): vm = vm_pool.get(self.uuid.value) diff --git a/common/classes.py b/common/classes.py index 2cea033..2eae809 100644 --- a/common/classes.py +++ b/common/classes.py @@ -1,28 +1,6 @@ -from decouple import Config, RepositoryEnv, UndefinedValueError from etcd3_wrapper import EtcdEntry -class EnvironmentVariables: - def __init__(self, env_file): - try: - env_config = Config(RepositoryEnv(env_file)) - except FileNotFoundError: - print("{} does not exists".format(env_file)) - exit(1) - else: - self.config = env_config - - def get(self, *args, **kwargs): - """Return value of var from env_vars""" - try: - value = self.config.get(*args, **kwargs) - except UndefinedValueError as e: - print(e) - exit(1) - else: - return value - - class SpecificEtcdEntryBase: def __init__(self, e: EtcdEntry): self.key = e.key diff --git a/common/helpers.py b/common/helpers.py index c0d64e4..1bdf0b4 100644 --- a/common/helpers.py +++ b/common/helpers.py @@ -1,5 +1,9 @@ import logging import socket +import requests +import json + +from ipaddress import ip_address from os.path import join as join_path @@ -37,3 +41,14 @@ def get_ipv4_address(): address = s.getsockname()[0] return address + + +def get_ipv6_address(): + try: + r = requests.get("https://api6.ipify.org?format=json") + content = json.loads(r.content.decode("utf-8")) + ip = ip_address(content["ip"]).exploded + except Exception as e: + logging.exception(e) + else: + return ip diff --git a/common/storage_handlers.py b/common/storage_handlers.py new file mode 100644 index 0000000..c74bca8 --- /dev/null +++ b/common/storage_handlers.py @@ -0,0 +1,158 @@ +import shutil +import subprocess as sp +import os +import stat + +from abc import ABC +from host import logger +from os.path import join as join_path + + +class ImageStorageHandler(ABC): + def __init__(self, image_base, vm_base): + self.image_base = image_base + self.vm_base = vm_base + + def import_image(self, image_src, image_dest, protect=False): + """Put an image at the destination + :param src: An Image file + :param dest: A path where :param src: is to be put. + :param protect: If protect is true then the dest is protect (readonly etc) + The obj must exist on filesystem. + """ + + raise NotImplementedError() + + def make_vm_image(self, image_path, path): + """Copy image from src to dest + + :param src: A path + :param dest: A path + + src and destination must be on same storage system i.e both on file system or both on CEPH etc. + """ + raise NotImplementedError() + + def resize_vm_image(self, path, size): + """Resize image located at :param path: + :param path: The file which is to be resized + :param size: Size must be in Megabytes + """ + raise NotImplementedError() + + def delete_vm_image(self, path): + raise NotImplementedError() + + def execute_command(self, command, report=True): + command = list(map(str, command)) + try: + output = sp.check_output(command, stderr=sp.PIPE) + except Exception as e: + if report: + print(e) + logger.exception(e) + return False + return True + + def vm_path_string(self, path): + raise NotImplementedError() + + def qemu_path_string(self, path): + raise NotImplementedError() + + def is_vm_image_exists(self, path): + raise NotImplementedError() + + +class FileSystemBasedImageStorageHandler(ImageStorageHandler): + def import_image(self, src, dest, protect=False): + dest = join_path(self.image_base, dest) + try: + shutil.copy(src, dest) + if protect: + os.chmod(dest, stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH) + except Exception as e: + logger.exception(e) + return False + return True + + def make_vm_image(self, src, dest): + src = join_path(self.image_base, src) + dest = join_path(self.vm_base, dest) + try: + shutil.copy(src, dest) + except Exception as e: + logger.exception(e) + return False + return True + + def resize_vm_image(self, path, size): + path = join_path(self.vm_base, path) + command = ["qemu-img", "resize", "-f", "raw", path, "{}M".format(size)] + if self.execute_command(command): + return True + else: + self.delete_vm_image(path) + return False + + def delete_vm_image(self, path): + path = join_path(self.vm_base, path) + try: + os.remove(path) + except Exception as e: + logger.exception(e) + return False + return True + + def vm_path_string(self, path): + return join_path(self.vm_base, path) + + def qemu_path_string(self, path): + return self.vm_path_string(path) + + def is_vm_image_exists(self, path): + path = join_path(self.vm_base, path) + command = ["ls", path] + return self.execute_command(command, report=False) + + +class CEPHBasedImageStorageHandler(ImageStorageHandler): + def import_image(self, src, dest, protect=False): + dest = join_path(self.image_base, dest) + command = ["rbd", "import", src, dest] + if protect: + snap_create_command = ["rbd", "snap", "create", "{}@protected".format(dest)] + snap_protect_command = ["rbd", "snap", "protect", "{}@protected".format(dest)] + + return self.execute_command(command) and self.execute_command(snap_create_command) and\ + self.execute_command(snap_protect_command) + + return self.execute_command(command) + + def make_vm_image(self, src, dest): + src = join_path(self.image_base, src) + dest = join_path(self.vm_base, dest) + + command = ["rbd", "clone", "{}@protected".format(src), dest] + return self.execute_command(command) + + def resize_vm_image(self, path, size): + path = join_path(self.vm_base, path) + command = ["rbd", "resize", path, "--size", size] + return self.execute_command(command) + + def delete_vm_image(self, path): + path = join_path(self.vm_base, path) + command = ["rbd", "rm", path] + return self.execute_command(command) + + def vm_path_string(self, path): + return join_path(self.vm_base, path) + + def qemu_path_string(self, path): + return "rbd:{}".format(self.vm_path_string(path)) + + def is_vm_image_exists(self, path): + path = join_path(self.vm_base, path) + command = ["rbd", "info", path] + return self.execute_command(command, report=False) diff --git a/common/vm.py b/common/vm.py index c778fac..c1c1928 100644 --- a/common/vm.py +++ b/common/vm.py @@ -60,10 +60,6 @@ class VMEntry(SpecificEtcdEntryBase): self.log = self.log[:5] self.log.append("{} - {}".format(datetime.now().isoformat(), msg)) - @property - def path(self): - return "rbd:uservms/{}".format(self.uuid) - class VmPool: def __init__(self, etcd_client, vm_prefix): diff --git a/config.py b/config.py index 5729fed..1048320 100644 --- a/config.py +++ b/config.py @@ -1,14 +1,16 @@ from etcd3_wrapper import Etcd3Wrapper -from common.classes import EnvironmentVariables from common.host import HostPool from common.request import RequestPool from common.vm import VmPool +from common.storage_handlers import FileSystemBasedImageStorageHandler, CEPHBasedImageStorageHandler +from decouple import Config, RepositoryEnv -env_vars = EnvironmentVariables('/etc/ucloud/ucloud.conf') + +env_vars = Config(RepositoryEnv('/etc/ucloud/ucloud.conf')) etcd_wrapper_args = () -etcd_wrapper_kwargs = {"host": env_vars.get("ETCD_URL")} +etcd_wrapper_kwargs = {'host': env_vars.get('ETCD_URL')} etcd_client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) @@ -17,3 +19,12 @@ vm_pool = VmPool(etcd_client, env_vars.get('VM_PREFIX')) request_pool = RequestPool(etcd_client, env_vars.get('REQUEST_PREFIX')) running_vms = [] + +__storage_backend = env_vars.get("STORAGE_BACKEND") +if __storage_backend == "filesystem": + image_storage_handler = FileSystemBasedImageStorageHandler(vm_base=env_vars.get("VM_DIR"), + image_base=env_vars.get("IMAGE_DIR")) +elif __storage_backend == "ceph": + image_storage_handler = CEPHBasedImageStorageHandler(vm_base="ssd", image_base="ssd") +else: + raise Exception("Unknown Image Storage Handler") diff --git a/docs/source/diagram-code/ucloud b/docs/source/diagram-code/ucloud new file mode 100644 index 0000000..5e73b3d --- /dev/null +++ b/docs/source/diagram-code/ucloud @@ -0,0 +1,44 @@ +graph LR + style ucloud fill:#FFD2FC + style cron fill:#FFF696 + style infrastructure fill:#BDF0FF + subgraph ucloud[ucloud] + ucloud-cli[CLI]-->ucloud-api[API] + ucloud-api-->ucloud-scheduler[Scheduler] + ucloud-api-->ucloud-imagescanner[Image Scanner] + ucloud-api-->ucloud-host[Host] + ucloud-scheduler-->ucloud-host + + ucloud-host-->need-networking{VM need Networking} + need-networking-->|Yes| networking-scripts + need-networking-->|No| VM[Virtual Machine] + need-networking-->|SLAAC?| radvd + networking-scripts-->VM + networking-scripts--Create Networks Devices-->networking-scripts + subgraph cron[Cron Jobs] + ucloud-imagescanner + ucloud-filescanner[File Scanner] + ucloud-filescanner--Track User files-->ucloud-filescanner + end + subgraph infrastructure[Infrastructure] + radvd + etcd + networking-scripts[Networking Scripts] + ucloud-imagescanner-->image-store + image-store{Image Store} + image-store-->|CEPH| ceph + image-store-->|FILE| file-system + ceph[CEPH] + file-system[File System] + end +subgraph virtual-machine[Virtual Machine] + VM + VM-->ucloud-init + +end + +subgraph metadata-group[Metadata Server] +metadata-->ucloud-init +ucloud-init<-->metadata +end +end diff --git a/docs/source/images/ucloud.svg b/docs/source/images/ucloud.svg new file mode 100644 index 0000000..f7e33f8 --- /dev/null +++ b/docs/source/images/ucloud.svg @@ -0,0 +1,494 @@ +
ucloud
Cron Jobs
Infrastructure
Virtual Machine
Metadata Server
Yes
No
SLAAC?
Create Networks Devices
Track User files
CEPH
FILE
API
CLI
Scheduler
Image Scanner
Host
VM need Networking
Networking Scripts
Virtual Machine
radvd
ucloud-init
metadata
etcd
Image Store
CEPH
File System
File Scanner
\ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index 0307de8..6443af1 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,7 +1,7 @@ .. ucloud documentation master file, created by -sphinx-quickstart on Mon Nov 11 19:08:16 2019. -You can adapt this file completely to your liking, but it should at least -contain the root `toctree` directive. + sphinx-quickstart on Mon Nov 11 19:08:16 2019. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. Welcome to ucloud's documentation! ================================== @@ -15,7 +15,9 @@ Welcome to ucloud's documentation! usage/usage-for-admins usage/usage-for-users usage/how-to-create-an-os-image-for-ucloud + theory/summary misc/todo + troubleshooting/installation-troubleshooting Indices and tables ================== diff --git a/docs/source/introduction/installation.rst b/docs/source/introduction/installation.rst index b271ab9..0f36714 100644 --- a/docs/source/introduction/installation.rst +++ b/docs/source/introduction/installation.rst @@ -135,7 +135,7 @@ You just need to update **AUTH_SEED** in the below code to match your auth's see ETCD_URL=localhost - WITHOUT_CEPH=True + STORAGE_BACKEND=filesystem BASE_DIR=/var/www IMAGE_DIR=/var/image @@ -195,3 +195,35 @@ profile e.g *~/.profile* alias uotp='cd /root/uotp/ && pipenv run python app.py' and run :code:`source ~/.profile` + + +Arch +----- + +.. code-block:: sh + + # Update/Upgrade + pacman -Syuu + pacman -S python3 qemu chrony python-pip + + pip3 install pipenv + + cat > /etc/chrony.conf << EOF + server 0.arch.pool.ntp.org + server 1.arch.pool.ntp.org + server 2.arch.pool.ntp.org + EOF + + systemctl start chronyd + systemctl enable chronyd + + # Create non-root user and allow it sudo access + # without password + useradd -m ucloud + echo "ucloud ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers + + sudo -H -u ucloud bash -c 'cd /home/ucloud && git clone https://aur.archlinux.org/yay.git && cd yay && makepkg -si' + sudo -H -u ucloud bash -c 'yay -S etcd' + + systemctl start etcd + systemctl enable etcd \ No newline at end of file diff --git a/docs/source/misc/todo.rst b/docs/source/misc/todo.rst index 3b85e89..4f7fde4 100644 --- a/docs/source/misc/todo.rst +++ b/docs/source/misc/todo.rst @@ -1,6 +1,18 @@ TODO ==== +* **Check Authentication:** Nico reported that some endpoints + even work without providing token. (ListUserVM) + +* Put overrides for **IMAGE_BASE**, **VM_BASE** in **ImageStorageHandler**. + +* Put "Always use only one StorageHandler" + +* Create Network Manager + * That would handle tasks like up/down an interface + * Create VXLANs, Bridges, TAPs. + * Remove them when they are no longer used. + * Check for :code:`etcd3.exceptions.ConnectionFailedError` when calling some etcd operation to avoid crashing whole application. * Throw KeyError instead of returning None when some key is not found in etcd. diff --git a/docs/source/theory/summary.rst b/docs/source/theory/summary.rst new file mode 100644 index 0000000..62f6200 --- /dev/null +++ b/docs/source/theory/summary.rst @@ -0,0 +1,98 @@ +Summary +======= + +.. image:: /images/ucloud.svg + +.. code-block:: + + + | + | + | + +------------------------- + | | + | |```````````````|```````````````| + | | | | + | + | | + | | + +------------------------- + | + | + | + Virtual Machine------------ + + + +**ucloud-cli** interact with **ucloud-api** to do the following operations: + +- Create/Delete/Start/Stop/Migrate/Probe (Status of) Virtual Machines +- Create/Delete Networks +- Add/Get/Delete SSH Keys +- Create OS Image out of a file (tracked by file_scanner) +- List User's files/networks/vms +- Add Host + +ucloud can currently stores OS-Images on + +* File System +* `CEPH `_ + + +**ucloud-api** in turns creates appropriate Requests which are taken +by suitable components of ucloud. For Example, if user uses ucloud-cli +to create a VM, **ucloud-api** would create a **ScheduleVMRequest** containing +things like pointer to VM's entry which have specs, networking +configuration of VMs. + +**ucloud-scheduler** accepts requests for VM's scheduling and +migration. It finds a host from a list of available host on which +the incoming VM can run and schedules it on that host. + +**ucloud-host** runs on host servers i.e servers that +actually runs virtual machines, accepts requests +intended only for them. It creates/delete/start/stop/migrate +virtual machines. It also arrange network resources needed for the +incoming VM. + +**ucloud-filescanner** keep tracks of user's files which would be needed +later for creating OS Images. + +**ucloud-imagescanner** converts images files from qcow2 format to raw +format which would then be imported into image store. + +* In case of **File System**, the converted image would be copied to + :file:`/var/image/` or the path referred by :envvar:`IMAGE_PATH` environement variable + mentioned in :file:`/etc/ucloud/ucloud.conf`. + +* In case of **CEPH**, the converted image would be imported into + specific pool (it depends on the image store in which the image + belongs) of CEPH Block Storage. + +**ucloud-metadata** provides metadata which is used to contextualize +VMs. When, the VM is created, it is just clone (duplicate) of OS +image from which it is created. So, to differentiate between my +VM and your VM, the VM need to be contextualized. This works +like the following + +.. note:: + Actually, ucloud-init makes the GET request. You can also try it + yourself using curl but ucloud-init does that for yourself. + +* VM make a GET requests http://metadata which resolves to actual + address of metadata server. The metadata server looks at the IPv6 + Address of the requester and extracts the MAC Address which is possible + because the IPv6 address is + `IPv6 EUI-64 `_. + Metadata use this MAC address to find the actual VM to which it belongs + and its owner, ssh-keys and much more. Then, metadata return these + details back to the calling VM in JSON format. These details are + then used be the **ucloud-init** which is explained next. + +**ucloud-init** gets the metadata from **ucloud-metadata** to contextualize +the VM. Specifically, it gets owner's ssh keys (or any other keys the +owner of VM added to authorized keys for this VM) and put them to ssh +server's (installed on VM) authorized keys so that owner can access +the VM using ssh. It also install softwares that are needed for correct +behavior of VM e.g rdnssd (needed for `SLAAC `_). + diff --git a/docs/source/troubleshooting/installation-troubleshooting.rst b/docs/source/troubleshooting/installation-troubleshooting.rst new file mode 100644 index 0000000..4d9dda4 --- /dev/null +++ b/docs/source/troubleshooting/installation-troubleshooting.rst @@ -0,0 +1,24 @@ +Installation Troubleshooting +============================ + +etcd doesn't start +------------------ + +.. code-block:: sh + + [root@archlinux ~]# systemctl start etcd + Job for etcd.service failed because the control process exited with error code. + See "systemctl status etcd.service" and "journalctl -xe" for details + +possible solution +~~~~~~~~~~~~~~~~~ +Try :code:`cat /etc/hosts` if its output contain the following + +.. code-block:: sh + + 127.0.0.1 localhost.localdomain localhost + ::1 localhost localhost.localdomain + + +then unfortunately, we can't help you. But, if it doesn't contain the +above you can put the above in :file:`/etc/hosts` to fix the issue. diff --git a/filescanner/__init__.py b/filescanner/__init__.py index e69de29..eea436a 100644 --- a/filescanner/__init__.py +++ b/filescanner/__init__.py @@ -0,0 +1,3 @@ +import logging + +logger = logging.getLogger(__name__) diff --git a/filescanner/main.py b/filescanner/main.py index d1ffa46..b80169c 100755 --- a/filescanner/main.py +++ b/filescanner/main.py @@ -6,7 +6,7 @@ import time from uuid import uuid4 from etcd3_wrapper import Etcd3Wrapper - +from filescanner import logger from config import env_vars @@ -17,9 +17,10 @@ def getxattr(file, attr): value = sp.check_output(['getfattr', file, '--name', attr, '--only-values', - '--absolute-names']) + '--absolute-names'], stderr=sp.DEVNULL) value = value.decode("utf-8") - except sp.CalledProcessError: + except sp.CalledProcessError as e: + logger.exception(e) value = None return value @@ -63,7 +64,7 @@ try: sp.check_output(['which', 'getfattr']) sp.check_output(['which', 'setfattr']) except Exception as e: - print(e) + logger.exception(e) print('Make sure you have getfattr and setfattr available') exit(1) diff --git a/host/helper.py b/host/helper.py new file mode 100644 index 0000000..edcb82d --- /dev/null +++ b/host/helper.py @@ -0,0 +1,13 @@ +import socket +from contextlib import closing + + +def find_free_port(): + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: + try: + s.bind(('', 0)) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + except Exception: + return None + else: + return s.getsockname()[1] diff --git a/host/main.py b/host/main.py index 5b5e620..f512fee 100755 --- a/host/main.py +++ b/host/main.py @@ -1,6 +1,5 @@ import argparse import multiprocessing as mp -import os import time from etcd3_wrapper import Etcd3Wrapper @@ -10,13 +9,17 @@ from config import (vm_pool, request_pool, etcd_client, running_vms, etcd_wrapper_args, etcd_wrapper_kwargs, HostPool, env_vars) + +from .helper import find_free_port from . import virtualmachine from host import logger -def update_heartbeat(host): + +def update_heartbeat(hostname): + """Update Last HeartBeat Time for :param hostname: in etcd""" client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) host_pool = HostPool(client, env_vars.get('HOST_PREFIX')) - this_host = next(filter(lambda h: h.hostname == host, host_pool.hosts), None) + this_host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) while True: this_host.update_heartbeat() @@ -35,17 +38,22 @@ def maintenance(host): # whether this host vm is successfully migrated. If yes # then we shutdown "vm1" on this host. + to_be_removed = [] for running_vm in running_vms: with vm_pool.get_put(running_vm.key) as vm_entry: if vm_entry.hostname != host.key and not vm_entry.in_migration: running_vm.handle.shutdown() - vm_entry.add_log("VM on source host shutdown.") + logger.info("VM migration not completed successfully.") + to_be_removed.append(running_vm) + + for r in to_be_removed: + running_vms.remove(r) + # To check vm running according to etcd entries alleged_running_vms = vm_pool.by_status("RUNNING", vm_pool.by_host(host.key)) for vm_entry in alleged_running_vms: _vm = virtualmachine.get_vm(running_vms, vm_entry.key) - # Whether, the allegedly running vm is in our # running_vms list or not if it is said to be # running on this host but it is not then we @@ -64,10 +72,6 @@ def maintenance(host): def main(hostname): - assert env_vars.get('WITHOUT_CEPH') and os.path.isdir(env_vars.get('VM_DIR')), ( - "You have set env_vars.get('WITHOUT_CEPH') to True. So, the vm directory mentioned" - " in .env file must exists. But, it don't.") - heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(hostname,)) host_pool = HostPool(etcd_client, env_vars.get('HOST_PREFIX')) @@ -99,7 +103,6 @@ def main(hostname): request_event = RequestEntry(request_event) if request_event.type == "TIMEOUT": - logger.info("Timeout Event") maintenance(host) continue @@ -121,7 +124,7 @@ def main(hostname): virtualmachine.delete(vm_entry) elif request_event.type == RequestType.InitVMMigration: - virtualmachine.init_migration(vm_entry, host.key) + virtualmachine.start(vm_entry, host.key, find_free_port()) elif request_event.type == RequestType.TransferVM: virtualmachine.transfer(request_event) diff --git a/host/qmp/__init__.py b/host/qmp/__init__.py index ba15838..775b397 100755 --- a/host/qmp/__init__.py +++ b/host/qmp/__init__.py @@ -304,6 +304,7 @@ class QEMUMachine(object): LOG.debug('Command: %r', ' '.join(self._qemu_full_args)) if self._iolog: LOG.debug('Output: %r', self._iolog) + raise Exception(self._iolog) raise def _launch(self): diff --git a/host/virtualmachine.py b/host/virtualmachine.py index 80e9846..5000410 100755 --- a/host/virtualmachine.py +++ b/host/virtualmachine.py @@ -4,27 +4,28 @@ # For QEMU Monitor Protocol Commands Information, See # https://qemu.weilnetz.de/doc/qemu-doc.html#pcsys_005fmonitor -import errno import os import random import subprocess as sp import tempfile import time + from functools import wraps -from os.path import join +from os.path import join as join_path from string import Template from typing import Union import bitmath import sshtunnel -from common.helpers import get_ipv4_address +from common.helpers import get_ipv6_address from common.request import RequestEntry, RequestType from common.vm import VMEntry, VMStatus -from config import etcd_client, request_pool, running_vms, vm_pool, env_vars +from config import etcd_client, request_pool, running_vms, vm_pool, env_vars, image_storage_handler from . import qmp from host import logger + class VM: def __init__(self, key, handle, vnc_socket_file): self.key = key # type: str @@ -106,24 +107,16 @@ def update_radvd_conf(etcd_client): sp.check_output(['systemctl', 'restart', 'radvd']) -def get_start_command_args( - vm_entry, vnc_sock_filename: str, migration=False, migration_port=4444, -): +def get_start_command_args(vm_entry, vnc_sock_filename: str, migration=False, migration_port=None): threads_per_core = 1 - vm_memory = int(bitmath.parse_string(vm_entry.specs["ram"]).to_MB()) + vm_memory = int(bitmath.parse_string_unsafe(vm_entry.specs["ram"]).to_MB()) vm_cpus = int(vm_entry.specs["cpu"]) vm_uuid = vm_entry.uuid vm_networks = vm_entry.network - if env_vars.get('WITHOUT_CEPH'): - command = "-drive file={},format=raw,if=virtio,cache=none".format( - os.path.join(env_vars.get('VM_DIR'), vm_uuid) - ) - else: - command = "-drive file=rbd:uservms/{},format=raw,if=virtio,cache=none".format( - vm_uuid - ) - + command = "-drive file={},format=raw,if=virtio,cache=none".format( + image_storage_handler.qemu_path_string(vm_uuid) + ) command += " -device virtio-rng-pci -vnc unix:{}".format(vnc_sock_filename) command += " -m {} -smp cores={},threads={}".format( vm_memory, vm_cpus, threads_per_core @@ -131,7 +124,7 @@ def get_start_command_args( command += " -name {}".format(vm_uuid) if migration: - command += " -incoming tcp:0:{}".format(migration_port) + command += " -incoming tcp:[::]:{}".format(migration_port) tap = None for network_and_mac in vm_networks: @@ -154,7 +147,7 @@ def get_start_command_args( return command.split(" ") -def create_vm_object(vm_entry, migration=False, migration_port=4444): +def create_vm_object(vm_entry, migration=False, migration_port=None): # NOTE: If migration suddenly stop working, having different # VNC unix filename on source and destination host can # be a possible cause of it. @@ -198,61 +191,19 @@ def need_running_vm(func): def create(vm_entry: VMEntry): - vm_hdd = int(bitmath.parse_string(vm_entry.specs["os-ssd"]).to_MB()) - - if env_vars.get('WITHOUT_CEPH'): - _command_to_create = [ - "cp", - os.path.join(env_vars.get('IMAGE_DIR'), vm_entry.image_uuid), - os.path.join(env_vars.get('VM_DIR'), vm_entry.uuid), - ] - - _command_to_extend = [ - "qemu-img", - "resize", - "-f", "raw", - os.path.join(env_vars.get('VM_DIR'), vm_entry.uuid), - "{}M".format(vm_hdd), - ] + if image_storage_handler.is_vm_image_exists(vm_entry.uuid): + # File Already exists. No Problem Continue + logger.debug("Image for vm %s exists", vm_entry.uuid) else: - _command_to_create = [ - "rbd", - "clone", - "images/{}@protected".format(vm_entry.image_uuid), - "uservms/{}".format(vm_entry.uuid), - ] - - _command_to_extend = [ - "rbd", - "resize", - "uservms/{}".format(vm_entry.uuid), - "--size", - vm_hdd, - ] - - try: - sp.check_output(_command_to_create) - except sp.CalledProcessError as e: - if e.returncode == errno.EEXIST: - logger.debug("Image for vm %s exists", vm_entry.uuid) - # File Already exists. No Problem Continue - return - - # This exception catches all other exceptions - # i.e FileNotFound (BaseImage), pool Does Not Exists etc. - logger.exception(e) - - vm_entry.status = "ERROR" - else: - try: - sp.check_output(_command_to_extend) - except Exception as e: - logger.exception(e) - else: - logger.info("New VM Created") + vm_hdd = int(bitmath.parse_string_unsafe(vm_entry.specs["os-ssd"]).to_MB()) + if image_storage_handler.make_vm_image(src=vm_entry.image_uuid, dest=vm_entry.uuid): + if not image_storage_handler.resize_vm_image(path=vm_entry.uuid, size=vm_hdd): + vm_entry.status = "ERROR" + else: + logger.info("New VM Created") -def start(vm_entry: VMEntry): +def start(vm_entry: VMEntry, destination_host_key=None, migration_port=None): _vm = get_vm(running_vms, vm_entry.key) # VM already running. No need to proceed further. @@ -260,8 +211,12 @@ def start(vm_entry: VMEntry): logger.info("VM %s already running", vm_entry.uuid) return else: - create(vm_entry) - launch_vm(vm_entry) + if destination_host_key: + launch_vm(vm_entry, migration=True, migration_port=migration_port, + destination_host_key=destination_host_key) + else: + create(vm_entry) + launch_vm(vm_entry) @need_running_vm @@ -278,18 +233,9 @@ def stop(vm_entry): def delete(vm_entry): logger.info("Deleting VM | %s", vm_entry) stop(vm_entry) - path_without_protocol = vm_entry.path[vm_entry.path.find(":") + 1:] - if env_vars.get('WITHOUT_CEPH'): - vm_deletion_command = ["rm", os.path.join(env_vars.get('VM_DIR'), vm_entry.uuid)] - else: - vm_deletion_command = ["rbd", "rm", path_without_protocol] - - try: - sp.check_output(vm_deletion_command) - except Exception as e: - logger.exception(e) - else: + r_status = image_storage_handler.delete_vm_image(vm_entry.uuid) + if r_status: etcd_client.client.delete(vm_entry.key) @@ -301,15 +247,16 @@ def transfer(request_event): _host, _port = request_event.parameters["host"], request_event.parameters["port"] _uuid = request_event.uuid _destination = request_event.destination_host_key - vm = get_vm(running_vms, join(env_vars.get('VM_PREFIX'), _uuid)) + vm = get_vm(running_vms, join_path(env_vars.get('VM_PREFIX'), _uuid)) if vm: tunnel = sshtunnel.SSHTunnelForwarder( - (_host, 22), + _host, ssh_username=env_vars.get("ssh_username"), ssh_pkey=env_vars.get("ssh_pkey"), - ssh_private_key_password=env_vars.get("ssh_private_key_password"), remote_bind_address=("127.0.0.1", _port), + ssh_proxy_enabled=True, + ssh_proxy=(_host, 22) ) try: tunnel.start() @@ -317,7 +264,7 @@ def transfer(request_event): logger.exception("Couldn't establish connection to (%s, 22)", _host) else: vm.handle.command( - "migrate", uri="tcp:{}:{}".format(_host, tunnel.local_bind_port) + "migrate", uri="tcp:0.0.0.0:{}".format(tunnel.local_bind_port) ) status = vm.handle.command("query-migrate")["status"] @@ -340,38 +287,22 @@ def transfer(request_event): tunnel.close() -def init_migration(vm_entry, destination_host_key): - # This function would run on destination host i.e host on which the vm - # would be transferred after migration. - # This host would be responsible for starting VM that would receive - # state of VM running on source host. - - _vm = get_vm(running_vms, vm_entry.key) - - if _vm: - # VM already running. No need to proceed further. - logger.info("%s Already running", _vm.key) - return - - launch_vm(vm_entry, migration=True, migration_port=4444, - destination_host_key=destination_host_key) - - def launch_vm(vm_entry, migration=False, migration_port=None, destination_host_key=None): logger.info("Starting %s", vm_entry.key) vm = create_vm_object(vm_entry, migration=migration, migration_port=migration_port) try: vm.handle.launch() - except Exception as e: - logger.exception(e) + except Exception: + logger.exception("Error Occured while starting VM") + vm.handle.shutdown() if migration: # We don't care whether MachineError or any other error occurred - vm.handle.shutdown() + pass else: # Error during typical launch of a vm - vm_entry.add_log("Error Occurred while starting VM") + vm.handle.shutdown() vm_entry.declare_killed() vm_pool.put(vm_entry) else: @@ -383,7 +314,7 @@ def launch_vm(vm_entry, migration=False, migration_port=None, destination_host_k r = RequestEntry.from_scratch( type=RequestType.TransferVM, hostname=vm_entry.hostname, - parameters={"host": get_ipv4_address(), "port": 4444}, + parameters={"host": get_ipv6_address(), "port": migration_port}, uuid=vm_entry.uuid, destination_host_key=destination_host_key, request_prefix=env_vars.get("REQUEST_PREFIX") diff --git a/imagescanner/main.py b/imagescanner/main.py index 97da589..4b41642 100755 --- a/imagescanner/main.py +++ b/imagescanner/main.py @@ -1,9 +1,9 @@ import json import os import subprocess -import sys -from config import etcd_client, env_vars +from os.path import join as join_path +from config import etcd_client, env_vars, image_storage_handler from imagescanner import logger @@ -20,20 +20,6 @@ def qemu_img_type(path): def main(): - # If you are using env_vars.get('WITHOUT_CEPH') FLAG in .env - # then please make sure that env_vars.get('IMAGE_DIR') directory - # exists otherwise this script would fail - if env_vars.get('WITHOUT_CEPH') and not os.path.isdir(env_vars.get('IMAGE_DIR')): - print("You have set env_vars.get('WITHOUT_CEPH') to True. So," - "the {} must exists. But, it don't".format(env_vars.get('IMAGE_DIR'))) - sys.exit(1) - - try: - subprocess.check_output(['which', 'qemu-img']) - except Exception: - print("qemu-img missing") - sys.exit(1) - # We want to get images entries that requests images to be created images = etcd_client.get_prefix(env_vars.get('IMAGE_PREFIX'), value_in_json=True) images_to_be_created = list(filter(lambda im: im.value['status'] == 'TO_BE_CREATED', images)) @@ -44,7 +30,7 @@ def main(): image_owner = image.value['owner'] image_filename = image.value['filename'] image_store_name = image.value['store_name'] - image_full_path = os.path.join(env_vars.get('BASE_DIR'), image_owner, image_filename) + image_full_path = join_path(env_vars.get('BASE_DIR'), image_owner, image_filename) image_stores = etcd_client.get_prefix(env_vars.get('IMAGE_STORE_PREFIX'), value_in_json=True) user_image_store = next(filter( @@ -58,43 +44,25 @@ def main(): logger.exception(e) else: # At least our basic data is available - qemu_img_convert_command = ["qemu-img", "convert", "-f", "qcow2", "-O", "raw", image_full_path, "image.raw"] - if env_vars.get('WITHOUT_CEPH'): - image_import_command = ["mv", "image.raw", os.path.join(env_vars.get('IMAGE_DIR'), image_uuid)] - snapshot_creation_command = ["true"] - snapshot_protect_command = ["true"] - else: - image_import_command = ["rbd", "import", "image.raw", - "{}/{}".format(image_store_pool, image_uuid)] - snapshot_creation_command = ["rbd", "snap", "create", - "{}/{}@protected".format(image_store_pool, image_uuid)] - snapshot_protect_command = ["rbd", "snap", "protect", - "{}/{}@protected".format(image_store_pool, image_uuid)] - - # First check whether the image is qcow2 - if qemu_img_type(image_full_path) == "qcow2": try: # Convert .qcow2 to .raw subprocess.check_output(qemu_img_convert_command) - - # Import image either to ceph/filesystem - subprocess.check_output(image_import_command) - - # Create and Protect Snapshot - subprocess.check_output(snapshot_creation_command) - subprocess.check_output(snapshot_protect_command) - except Exception as e: logger.exception(e) - else: - # Everything is successfully done - image.value["status"] = "CREATED" - etcd_client.put(image.key, json.dumps(image.value)) + # Import and Protect + r_status = image_storage_handler.import_image(src="image.raw", + dest=image_uuid, + protect=True) + if r_status: + # Everything is successfully done + image.value["status"] = "CREATED" + etcd_client.put(image.key, json.dumps(image.value)) + else: # The user provided image is either not found or of invalid format image.value["status"] = "INVALID_IMAGE" diff --git a/sanity_checks.py b/sanity_checks.py new file mode 100644 index 0000000..2c645a5 --- /dev/null +++ b/sanity_checks.py @@ -0,0 +1,33 @@ +import sys +import subprocess as sp + +from os.path import isdir +from config import env_vars + + +def check(): + ######################### + # ucloud-image-scanner # + ######################### + if env_vars.get('STORAGE_BACKEND') == 'filesystem' and not isdir(env_vars.get('IMAGE_DIR')): + print("You have set STORAGE_BACKEND to filesystem. So," + "the {} must exists. But, it don't".format(env_vars.get('IMAGE_DIR'))) + sys.exit(1) + + try: + sp.check_output(['which', 'qemu-img']) + except Exception: + print("qemu-img missing") + sys.exit(1) + + ############### + # ucloud-host # + ############### + + if env_vars.get('STORAGE_BACKEND') == 'filesystem' and not isdir(env_vars.get('VM_DIR')): + print("You have set STORAGE_BACKEND to filesystem. So, the vm directory mentioned" + " in .env file must exists. But, it don't.") + sys.exit(1) + +if __name__ == "__main__": + check() \ No newline at end of file diff --git a/scheduler/helper.py b/scheduler/helper.py index 81b5869..79bfd70 100755 --- a/scheduler/helper.py +++ b/scheduler/helper.py @@ -23,16 +23,16 @@ def remaining_resources(host_specs, vms_specs): for component in _vms_specs: if isinstance(_vms_specs[component], str): - _vms_specs[component] = int(bitmath.parse_string(_vms_specs[component]).to_MB()) + _vms_specs[component] = int(bitmath.parse_string_unsafe(_vms_specs[component]).to_MB()) elif isinstance(_vms_specs[component], list): - _vms_specs[component] = map(lambda x: int(bitmath.parse_string(x).to_MB()), _vms_specs[component]) + _vms_specs[component] = map(lambda x: int(bitmath.parse_string_unsafe(x).to_MB()), _vms_specs[component]) _vms_specs[component] = reduce(lambda x, y: x + y, _vms_specs[component], 0) for component in _remaining: if isinstance(_remaining[component], str): - _remaining[component] = int(bitmath.parse_string(_remaining[component]).to_MB()) + _remaining[component] = int(bitmath.parse_string_unsafe(_remaining[component]).to_MB()) elif isinstance(_remaining[component], list): - _remaining[component] = map(lambda x: int(bitmath.parse_string(x).to_MB()), _remaining[component]) + _remaining[component] = map(lambda x: int(bitmath.parse_string_unsafe(x).to_MB()), _remaining[component]) _remaining[component] = reduce(lambda x, y: x + y, _remaining[component], 0) _remaining.subtract(_vms_specs) diff --git a/scheduler/main.py b/scheduler/main.py index 507ac44..1d8dc44 100755 --- a/scheduler/main.py +++ b/scheduler/main.py @@ -23,8 +23,6 @@ def main(): ]: for request_event in request_iterator: request_entry = RequestEntry(request_event) - logger.debug("%s, %s", request_entry.key, request_entry.value) - # Never Run time critical mechanism inside timeout # mechanism because timeout mechanism only comes # when no other event is happening. It means under @@ -33,10 +31,10 @@ def main(): # Detect hosts that are dead and set their status # to "DEAD", and their VMs' status to "KILLED" - logger.debug("TIMEOUT event occured") dead_hosts = dead_host_detection() - logger.debug("Dead hosts: %s", dead_hosts) - dead_host_mitigation(dead_hosts) + if dead_hosts: + logger.debug("Dead hosts: %s", dead_hosts) + dead_host_mitigation(dead_hosts) # If there are VMs that weren't assigned a host # because there wasn't a host available which @@ -52,6 +50,8 @@ def main(): request_pool.put(r) elif request_entry.type == RequestType.ScheduleVM: + logger.debug("%s, %s", request_entry.key, request_entry.value) + vm_entry = vm_pool.get(request_entry.uuid) if vm_entry is None: logger.info("Trying to act on {} but it is deleted".format(request_entry.uuid)) @@ -67,7 +67,7 @@ def main(): hosts=[host_pool.get(request_entry.destination)]) except NoSuitableHostFound: logger.info("Requested destination host doesn't have enough capacity" - "to hold %s", vm_entry.uuid) + "to hold %s" % vm_entry.uuid) else: r = RequestEntry.from_scratch(type=RequestType.InitVMMigration, uuid=request_entry.uuid, diff --git a/ucloud.py b/ucloud.py index 8774fa3..28979b3 100644 --- a/ucloud.py +++ b/ucloud.py @@ -3,6 +3,7 @@ import multiprocessing as mp import logging from os.path import join as join_path +from sanity_checks import check if __name__ == "__main__": arg_parser = argparse.ArgumentParser(prog='ucloud', @@ -21,30 +22,36 @@ if __name__ == "__main__": format="%(name)s %(asctime)s: %(levelname)s - %(message)s", datefmt="%d-%b-%y %H:%M:%S", ) + try: + check() - if args.component == 'api': - from api.main import main + if args.component == 'api': + from api.main import main - main() - elif args.component == 'host': - from host.main import main + main() + elif args.component == 'host': + from host.main import main - hostname = args.component_args - mp.set_start_method('spawn') - main(*hostname) - elif args.component == 'scheduler': - from scheduler.main import main + hostname = args.component_args + mp.set_start_method('spawn') + main(*hostname) + elif args.component == 'scheduler': + from scheduler.main import main - main() - elif args.component == 'filescanner': - from filescanner.main import main + main() + elif args.component == 'filescanner': + from filescanner.main import main - main() - elif args.component == 'imagescanner': - from imagescanner.main import main + main() + elif args.component == 'imagescanner': + from imagescanner.main import main - main() - elif args.component == 'metadata': - from metadata.main import main + main() + elif args.component == 'metadata': + from metadata.main import main - main() + main() + + except Exception as e: + logging.exception(e) + print(e) \ No newline at end of file From befb22b9cb83a1036cd2786717395bb74181b978 Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 25 Nov 2019 11:57:16 +0500 Subject: [PATCH 016/284] TODO.md removed from root --- TODO.md | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index c65196c..0000000 --- a/TODO.md +++ /dev/null @@ -1,6 +0,0 @@ -# TODO - -- Check for `etcd3.exceptions.ConnectionFailedError` when calling some etcd operation to - avoid crashing whole application -- Throw KeyError instead of returning None when some key is not found in etcd -- Expose more details in ListUserFiles \ No newline at end of file From f3f2f6127aaf31170d30745bc174e09f7f559a02 Mon Sep 17 00:00:00 2001 From: meow Date: Wed, 27 Nov 2019 12:12:29 +0500 Subject: [PATCH 017/284] Effort is made to ensure a VM always have a status and Unused VM statuses are removed --- Pipfile | 1 + Pipfile.lock | 14 +++++++-- api/main.py | 4 +-- common/vm.py | 16 ---------- docs/source/conf.py | 6 ++-- docs/source/introduction/installation.rst | 37 +++------------------- docs/source/misc/todo.rst | 29 ++++++++++++----- docs/source/usage/usage-for-users.rst | 38 ++++++++++++++++++++--- host/main.py | 2 +- host/virtualmachine.py | 12 ++++--- scheduler/main.py | 4 +-- 11 files changed, 86 insertions(+), 77 deletions(-) diff --git a/Pipfile b/Pipfile index ec5b001..63c2610 100644 --- a/Pipfile +++ b/Pipfile @@ -20,6 +20,7 @@ sshtunnel = "*" helper = "*" sphinx = "*" pynetbox = "*" +sphinx-rtd-theme = "*" [requires] python_version = "3.5" diff --git a/Pipfile.lock b/Pipfile.lock index b9373d5..5853a4b 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f43a93c020eb20212b437fcc62882db03bfa93f4678eb930e31343d687c805ed" + "sha256": "7f5bc76f02cef7e98fa631f1954b2b7afa46a7796650386b91c9a6c591945f75" }, "pipfile-spec": 6, "requires": { @@ -379,10 +379,10 @@ }, "pynetbox": { "hashes": [ - "sha256:09525a29f1ac8c1a54772d6e2b94a55b1db6ba6a1c5b07f7af6a6ce232b1f7d5" + "sha256:7c2282891ab1d3a5f5b28cb3b83c30d33c7ac3da1ee928c7332a4d2fac32f283" ], "index": "pypi", - "version": "==4.1.0" + "version": "==4.2.0" }, "pyotp": { "hashes": [ @@ -466,6 +466,14 @@ "index": "pypi", "version": "==2.2.1" }, + "sphinx-rtd-theme": { + "hashes": [ + "sha256:00cf895504a7895ee433807c62094cf1e95f065843bf3acd17037c3e9a2becd4", + "sha256:728607e34d60456d736cc7991fd236afb828b21b82f956c5ea75f94c8414040a" + ], + "index": "pypi", + "version": "==0.4.3" + }, "sphinxcontrib-applehelp": { "hashes": [ "sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897", diff --git a/api/main.py b/api/main.py index 59b7dc0..4b6d7bb 100644 --- a/api/main.py +++ b/api/main.py @@ -1,5 +1,4 @@ import json -import subprocess import pynetbox from uuid import uuid4 @@ -9,6 +8,7 @@ from flask import Flask, request from flask_restful import Resource, Api from common import counters +from common.vm import VMStatus from common.request import RequestEntry, RequestType from config import (etcd_client, request_pool, vm_pool, host_pool, env_vars, image_storage_handler) from . import schemas @@ -42,7 +42,7 @@ class CreateVM(Resource): "owner_realm": data["realm"], "specs": specs, "hostname": "", - "status": "", + "status": VMStatus.stopped, "image_uuid": validator.image_uuid, "log": [], "vnc_socket": "", diff --git a/common/vm.py b/common/vm.py index c1c1928..1f5e43e 100644 --- a/common/vm.py +++ b/common/vm.py @@ -6,25 +6,9 @@ from .classes import SpecificEtcdEntryBase class VMStatus: - # Must be only assigned to brand new VM - requested_new = "REQUESTED_NEW" - - # Only Assigned to already created vm - requested_start = "REQUESTED_START" - - # These all are for running vms - requested_shutdown = "REQUESTED_SHUTDOWN" - requested_migrate = "REQUESTED_MIGRATE" - requested_delete = "REQUESTED_DELETE" - # either its image is not found or user requested - # to delete it - deleted = "DELETED" - stopped = "STOPPED" # After requested_shutdown killed = "KILLED" # either host died or vm died itself - running = "RUNNING" - error = "ERROR" # An error occurred that cannot be resolved automatically diff --git a/docs/source/conf.py b/docs/source/conf.py index 64509c4..9b133f9 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -27,7 +27,8 @@ author = 'ungleich' # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc' + 'sphinx.ext.autodoc', + 'sphinx_rtd_theme', ] # Add any paths that contain templates here, relative to this directory. @@ -43,7 +44,8 @@ exclude_patterns = [] # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'alabaster' + +html_theme = "sphinx_rtd_theme" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/docs/source/introduction/installation.rst b/docs/source/introduction/installation.rst index 0f36714..450a9e7 100644 --- a/docs/source/introduction/installation.rst +++ b/docs/source/introduction/installation.rst @@ -11,8 +11,10 @@ Installation Alpine ------ -Python Wheel (Binary) Packages does not support Alpine Linux as it is using musl libc instead of glibc. -Therefore, expect longer installation times than other linux distributions. + +.. note:: + Python Wheel (Binary) Packages does not support Alpine Linux as it is using musl libc instead of glibc. + Therefore, expect longer installation times than other linux distributions. Enable Edge Repos, Update and Upgrade ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -196,34 +198,3 @@ profile e.g *~/.profile* and run :code:`source ~/.profile` - -Arch ------ - -.. code-block:: sh - - # Update/Upgrade - pacman -Syuu - pacman -S python3 qemu chrony python-pip - - pip3 install pipenv - - cat > /etc/chrony.conf << EOF - server 0.arch.pool.ntp.org - server 1.arch.pool.ntp.org - server 2.arch.pool.ntp.org - EOF - - systemctl start chronyd - systemctl enable chronyd - - # Create non-root user and allow it sudo access - # without password - useradd -m ucloud - echo "ucloud ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers - - sudo -H -u ucloud bash -c 'cd /home/ucloud && git clone https://aur.archlinux.org/yay.git && cd yay && makepkg -si' - sudo -H -u ucloud bash -c 'yay -S etcd' - - systemctl start etcd - systemctl enable etcd \ No newline at end of file diff --git a/docs/source/misc/todo.rst b/docs/source/misc/todo.rst index 4f7fde4..d932b70 100644 --- a/docs/source/misc/todo.rst +++ b/docs/source/misc/todo.rst @@ -1,19 +1,32 @@ TODO ==== +Security +-------- + * **Check Authentication:** Nico reported that some endpoints - even work without providing token. (ListUserVM) + even work without providing token. (e.g ListUserVM) + +Refactoring/Feature +------------------- * Put overrides for **IMAGE_BASE**, **VM_BASE** in **ImageStorageHandler**. - -* Put "Always use only one StorageHandler" - +* Expose more details in ListUserFiles. +* Throw KeyError instead of returning None when some key is not found in etcd. * Create Network Manager * That would handle tasks like up/down an interface * Create VXLANs, Bridges, TAPs. * Remove them when they are no longer used. -* Check for :code:`etcd3.exceptions.ConnectionFailedError` when calling some etcd operation to - avoid crashing whole application. -* Throw KeyError instead of returning None when some key is not found in etcd. -* Expose more details in ListUserFiles. \ No newline at end of file +Reliability +----------- + +* What to do if some command hangs forever? e.g CEPH commands + :code:`rbd ls ssd` etc. hangs forever if CEPH isn't running + or not responding. +* What to do if etcd goes down? + +Misc. +----- + +* Put "Always use only one StorageHandler" diff --git a/docs/source/usage/usage-for-users.rst b/docs/source/usage/usage-for-users.rst index 39d6fce..315fa80 100644 --- a/docs/source/usage/usage-for-users.rst +++ b/docs/source/usage/usage-for-users.rst @@ -69,21 +69,49 @@ Then, launch your remote desktop client and connect to vnc://localhost:1234. Create Network -------------- +Layer 2 Network with sample IPv6 range fd00::/64 (without IPAM and routing) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: sh ucloud-cli network create --network-name mynet --network-type vxlan -.. code-block:: json +Layer 2 Network with /64 network with automatic IPAM +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. code-block:: sh - { - "message": "Network successfully added." - } + ucloud-cli network create --network-name mynet --network-type vxlan --user True -Create VM using this network +Attach Network to VM +-------------------- + +Currently, user can only attach network to his/her VM at +the time of creation. A sample command to create VM with +a network is as follow .. code-block:: sh ucloud-cli vm create --vm-name meow2 --cpu 1 --ram '1gb' --os-ssd '4gb' --image images:alpine --network mynet +.. _get-list-of-hosts: +Get List of Hosts +----------------- + +.. code-block:: sh + + ucloud-cli host list + + +Migrate VM +---------- + +.. code-block:: sh + + ucloud-cli vm migrate --vm-name meow --destination server1.place10 + + +.. option:: --destination + + The name of destination host. You can find a list of host + using :ref:`get-list-of-hosts` \ No newline at end of file diff --git a/host/main.py b/host/main.py index f512fee..a51e09a 100755 --- a/host/main.py +++ b/host/main.py @@ -108,7 +108,7 @@ def main(hostname): # If the event is directed toward me OR I am destination of a InitVMMigration if request_event.hostname == host.key or request_event.destination == host.key: - logger.debug("EVENT: %s", request_event) + logger.debug("VM Request: %s", request_event) request_pool.client.client.delete(request_event.key) vm_entry = vm_pool.get(request_event.uuid) diff --git a/host/virtualmachine.py b/host/virtualmachine.py index 5000410..53de948 100755 --- a/host/virtualmachine.py +++ b/host/virtualmachine.py @@ -114,14 +114,15 @@ def get_start_command_args(vm_entry, vnc_sock_filename: str, migration=False, mi vm_uuid = vm_entry.uuid vm_networks = vm_entry.network - command = "-drive file={},format=raw,if=virtio,cache=none".format( + command = "-name {}_{}".format(vm_entry.owner, vm_entry.name) + + command += " -drive file={},format=raw,if=virtio,cache=none".format( image_storage_handler.qemu_path_string(vm_uuid) ) command += " -device virtio-rng-pci -vnc unix:{}".format(vnc_sock_filename) command += " -m {} -smp cores={},threads={}".format( vm_memory, vm_cpus, threads_per_core ) - command += " -name {}".format(vm_uuid) if migration: command += " -incoming tcp:[::]:{}".format(migration_port) @@ -198,7 +199,7 @@ def create(vm_entry: VMEntry): vm_hdd = int(bitmath.parse_string_unsafe(vm_entry.specs["os-ssd"]).to_MB()) if image_storage_handler.make_vm_image(src=vm_entry.image_uuid, dest=vm_entry.uuid): if not image_storage_handler.resize_vm_image(path=vm_entry.uuid, size=vm_hdd): - vm_entry.status = "ERROR" + vm_entry.status = VMStatus.error else: logger.info("New VM Created") @@ -208,9 +209,10 @@ def start(vm_entry: VMEntry, destination_host_key=None, migration_port=None): # VM already running. No need to proceed further. if _vm: - logger.info("VM %s already running", vm_entry.uuid) + logger.info("VM %s already running" % vm_entry.uuid) return else: + logger.info("Trying to start %s" % vm_entry.uuid) if destination_host_key: launch_vm(vm_entry, migration=True, migration_port=migration_port, destination_host_key=destination_host_key) @@ -288,7 +290,7 @@ def transfer(request_event): def launch_vm(vm_entry, migration=False, migration_port=None, destination_host_key=None): - logger.info("Starting %s", vm_entry.key) + logger.info("Starting %s" % vm_entry.key) vm = create_vm_object(vm_entry, migration=migration, migration_port=migration_port) try: diff --git a/scheduler/main.py b/scheduler/main.py index 1d8dc44..4511dcc 100755 --- a/scheduler/main.py +++ b/scheduler/main.py @@ -67,7 +67,7 @@ def main(): hosts=[host_pool.get(request_entry.destination)]) except NoSuitableHostFound: logger.info("Requested destination host doesn't have enough capacity" - "to hold %s" % vm_entry.uuid) + "to hold %s" % vm_entry.uuid) else: r = RequestEntry.from_scratch(type=RequestType.InitVMMigration, uuid=request_entry.uuid, @@ -82,7 +82,7 @@ def main(): try: assign_host(vm_entry) except NoSuitableHostFound: - vm_entry.log.append("Can't schedule VM. No Resource Left.") + vm_entry.add_log("Can't schedule VM. No Resource Left.") vm_pool.put(vm_entry) pending_vms.append(vm_entry) From e890c45dbfaed3985d551355dcd950d457855309 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 27 Nov 2019 11:05:01 +0100 Subject: [PATCH 018/284] Ignore etcd leftovers --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 55adfaf..7764afb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ __pycache__ docs/build -logs.txt \ No newline at end of file +logs.txt + +default.etcd From 597dedb1ff24b0c12a22750ae43f77585f625383 Mon Sep 17 00:00:00 2001 From: meow Date: Wed, 27 Nov 2019 15:35:51 +0500 Subject: [PATCH 019/284] better etcd inits --- config.py | 8 +++++++- filescanner/main.py | 5 +---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/config.py b/config.py index 1048320..c58cf33 100644 --- a/config.py +++ b/config.py @@ -10,7 +10,13 @@ from decouple import Config, RepositoryEnv env_vars = Config(RepositoryEnv('/etc/ucloud/ucloud.conf')) etcd_wrapper_args = () -etcd_wrapper_kwargs = {'host': env_vars.get('ETCD_URL')} +etcd_wrapper_kwargs = { + 'host': env_vars.get('ETCD_URL', 'localhost'), + 'port': env_vars.get('ETCD_PORT', 2379), + 'ca_cert': env_vars.get('CA_CERT', None), + 'cert_cert': env_vars.get('CERT_CERT', None), + 'cert_key': env_vars.get('CERT_KEY', None) +} etcd_client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) diff --git a/filescanner/main.py b/filescanner/main.py index b80169c..385d31d 100755 --- a/filescanner/main.py +++ b/filescanner/main.py @@ -5,9 +5,8 @@ import subprocess as sp import time from uuid import uuid4 -from etcd3_wrapper import Etcd3Wrapper from filescanner import logger -from config import env_vars +from config import env_vars, etcd_client def getxattr(file, attr): @@ -74,8 +73,6 @@ def main(): FILE_PREFIX = env_vars.get("FILE_PREFIX") - etcd_client = Etcd3Wrapper(host=env_vars.get("ETCD_URL")) - # Recursively Get All Files and Folder below BASE_DIR files = glob.glob("{}/**".format(BASE_DIR), recursive=True) From fd042eb85d16319a706d375be554a0143c46a4b5 Mon Sep 17 00:00:00 2001 From: meow Date: Wed, 27 Nov 2019 15:38:26 +0500 Subject: [PATCH 020/284] default.etcd removed from .gitignore as it is alpine only issue that forces us to use start-stop-daemon to start etcd cluster which uses the cur dir for storing data --- .gitignore | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 7764afb..55adfaf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,4 @@ __pycache__ docs/build -logs.txt - -default.etcd +logs.txt \ No newline at end of file From 5be0e26669bcb30e81fc095c8fe835de6b9c917d Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 27 Nov 2019 11:54:06 +0100 Subject: [PATCH 021/284] ++ hacking in ucloud --- hack/README.org | 7 +++++ hack/nftables.conf | 77 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 hack/README.org create mode 100644 hack/nftables.conf diff --git a/hack/README.org b/hack/README.org new file mode 100644 index 0000000..a4668dd --- /dev/null +++ b/hack/README.org @@ -0,0 +1,7 @@ +This directory contains unfinishe hacks / inspirations +* firewalling / networking in ucloud +** automatically route a network per VM - /64? +** nft: one chain per VM on each vm host (?) +*** might have scaling issues? +** firewall rules on each VM host + - mac filtering: diff --git a/hack/nftables.conf b/hack/nftables.conf new file mode 100644 index 0000000..3758db0 --- /dev/null +++ b/hack/nftables.conf @@ -0,0 +1,77 @@ +flush ruleset + +table bridge filter { + chain prerouting { + type filter hook prerouting priority 0; + policy accept; + ibrname br100 jump netpublic + } + chain netpublic { + icmpv6 type {nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, nd-redirect } log + } +} + +table ip6 filter { + chain forward { + type filter hook forward priority 0; + + # this would be nice... + policy drop; + + ct state established,related accept; + + } + + chain prerouting { + type filter hook prerouting priority 0; + policy accept; + + # not supporting in here! + + + iifname vmXXXX jump vmXXXX + iifname vmYYYY jump vmYYYY + + iifname brXX jump brXX + + iifname vxlan100 jump vxlan100 + iifname br100 jump br100 + } + + # 1. Rules per VM (names: vmXXXXX? + # 2. Rules per network (names: vxlanXXXX, what about non vxlan?) + # 3. Rules per bridge: + # vxlanXX is inside brXX + # This is effectively a network filter + # 4. Kill all malicous traffic: + # - router advertisements from VMs in which they should not announce RAs + + + + chain vxlan100 { + icmpv6 type {nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, nd-redirect } log + } + chain br100 { + icmpv6 type {nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, nd-redirect } log + } + + chain netpublic { + # drop router advertisements that don't come from us + iifname != vxlanpublic icmpv6 type {nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, nd-redirect } drop + # icmpv6 type {nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, nd-redirect } drop + + } + + # This vlan + chain brXX { + ip6 saddr != 2001:db8:1::/64 drop; + } + + chain vmXXXX { + ether saddr != 00:0f:54:0c:11:04 drop; + } + + chain vmYYYY { + ether saddr != 00:0f:54:0c:11:05 drop; + } +} \ No newline at end of file From db7fcdd66fb52d89eba85f7cf81dbbb50acad80e Mon Sep 17 00:00:00 2001 From: meow Date: Wed, 27 Nov 2019 19:19:57 +0500 Subject: [PATCH 022/284] add rc-scripts and confs. Make log message clear when host is not found --- hack/conf.d/ucloud-host | 1 + hack/rc-scripts/ucloud-api | 8 ++++++++ hack/rc-scripts/ucloud-host | 8 ++++++++ hack/rc-scripts/ucloud-scheduler | 8 ++++++++ host/main.py | 2 +- 5 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 hack/conf.d/ucloud-host create mode 100644 hack/rc-scripts/ucloud-api create mode 100644 hack/rc-scripts/ucloud-host create mode 100644 hack/rc-scripts/ucloud-scheduler diff --git a/hack/conf.d/ucloud-host b/hack/conf.d/ucloud-host new file mode 100644 index 0000000..d1dd8d1 --- /dev/null +++ b/hack/conf.d/ucloud-host @@ -0,0 +1 @@ +HOSTNAME=server1.place10 \ No newline at end of file diff --git a/hack/rc-scripts/ucloud-api b/hack/rc-scripts/ucloud-api new file mode 100644 index 0000000..eb7f83e --- /dev/null +++ b/hack/rc-scripts/ucloud-api @@ -0,0 +1,8 @@ +#!/sbin/openrc-run + +name="$RC_SVCNAME" +pidfile="/var/run/${name}.pid" +command="$(which pipenv)" +command_args="run python ucloud.py api" +command_background="true" +directory="/root/ucloud" \ No newline at end of file diff --git a/hack/rc-scripts/ucloud-host b/hack/rc-scripts/ucloud-host new file mode 100644 index 0000000..0aa375f --- /dev/null +++ b/hack/rc-scripts/ucloud-host @@ -0,0 +1,8 @@ +#!/sbin/openrc-run + +name="$RC_SVCNAME" +pidfile="/var/run/${name}.pid" +command="$(which pipenv)" +command_args="run python ucloud.py host ${HOSTNAME}" +command_background="true" +directory="/root/ucloud" \ No newline at end of file diff --git a/hack/rc-scripts/ucloud-scheduler b/hack/rc-scripts/ucloud-scheduler new file mode 100644 index 0000000..00c0a36 --- /dev/null +++ b/hack/rc-scripts/ucloud-scheduler @@ -0,0 +1,8 @@ +#!/sbin/openrc-run + +name="$RC_SVCNAME" +pidfile="/var/run/${name}.pid" +command="$(which pipenv)" +command_args="run python ucloud.py scheduler" +command_background="true" +directory="/root/ucloud" \ No newline at end of file diff --git a/host/main.py b/host/main.py index a51e09a..c5c5887 100755 --- a/host/main.py +++ b/host/main.py @@ -76,7 +76,7 @@ def main(hostname): host_pool = HostPool(etcd_client, env_vars.get('HOST_PREFIX')) host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) - assert host is not None, "No such host" + assert host is not None, "No such host with name = {}".format(hostname) try: heartbeat_updating_process.start() From 46c14306ec79a806dcf9cbce7b5af81f703f3847 Mon Sep 17 00:00:00 2001 From: meow Date: Wed, 27 Nov 2019 22:20:33 +0500 Subject: [PATCH 023/284] otp verification endpoint corrected --- api/helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/helper.py b/api/helper.py index eb32373..ed3ea28 100755 --- a/api/helper.py +++ b/api/helper.py @@ -25,7 +25,7 @@ def check_otp(name, realm, token): response = requests.post( "{OTP_SERVER}{OTP_VERIFY_ENDPOINT}".format( OTP_SERVER=env_vars.get("OTP_SERVER", ""), - OTP_VERIFY_ENDPOINT=env_vars.get("OTP_VERIFY_ENDPOINT", "verify"), + OTP_VERIFY_ENDPOINT=env_vars.get("OTP_VERIFY_ENDPOINT", "verify/"), ), json=data, ) From 66b7cf525f64ca091dc7f426ea399bda8b0bc3b3 Mon Sep 17 00:00:00 2001 From: meow Date: Wed, 27 Nov 2019 23:48:38 +0500 Subject: [PATCH 024/284] alternative radvd restart command added --- host/virtualmachine.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/host/virtualmachine.py b/host/virtualmachine.py index 53de948..e9fb919 100755 --- a/host/virtualmachine.py +++ b/host/virtualmachine.py @@ -103,9 +103,10 @@ def update_radvd_conf(etcd_client): with open('/etc/radvd.conf', 'w') as radvd_conf: radvd_conf.writelines(content) - - sp.check_output(['systemctl', 'restart', 'radvd']) - + try: + sp.check_output(['systemctl', 'restart', 'radvd']) + except Exception: + sp.check_output(['service', 'radvd', 'restart']) def get_start_command_args(vm_entry, vnc_sock_filename: str, migration=False, migration_port=None): threads_per_core = 1 From bbe09667a6564f0043f13321a36b45b177316fbc Mon Sep 17 00:00:00 2001 From: meow Date: Thu, 28 Nov 2019 13:04:53 +0500 Subject: [PATCH 025/284] fixed tap id for each NIC, add more logging when VM is declared killed --- api/main.py | 8 +++++--- host/main.py | 1 + host/virtualmachine.py | 24 +++++++++++++++--------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/api/main.py b/api/main.py index 4b6d7bb..224ab2e 100644 --- a/api/main.py +++ b/api/main.py @@ -36,6 +36,8 @@ class CreateVM(Resource): "hdd": validator.specs["hdd"], } macs = [generate_mac() for i in range(len(data["network"]))] + tap_ids = [counters.increment_etcd_counter(etcd_client, "/v1/counter/tap") + for i in range(len(data["network"]))] vm_entry = { "name": data["vm_name"], "owner": data["name"], @@ -46,7 +48,7 @@ class CreateVM(Resource): "image_uuid": validator.image_uuid, "log": [], "vnc_socket": "", - "network": list(zip(data["network"], macs)), + "network": list(zip(data["network"], macs, tap_ids)), "metadata": {"ssh-keys": []}, } etcd_client.put(vm_key, vm_entry, value_in_json=True) @@ -73,8 +75,8 @@ class VmStatus(Resource): ) vm_value = vm.value.copy() vm_value["ip"] = [] - for network_and_mac in vm.network: - network_name, mac = network_and_mac + for network_mac_and_tap in vm.network: + network_name, mac, tap = network_mac_and_tap network = etcd_client.get( join_path( env_vars.get("NETWORK_PREFIX"), diff --git a/host/main.py b/host/main.py index c5c5887..9b12c30 100755 --- a/host/main.py +++ b/host/main.py @@ -63,6 +63,7 @@ def maintenance(host): # initiated by user inside VM. OR crash of VM by some # user running process if (_vm and not _vm.handle.is_running()) or not _vm: + logger.debug("_vm = %s, is_running() = %s" % (_vm, _vm.handle.is_running())) vm_entry.add_log("""{} is not running but is said to be running. So, shutting it down and declare it killed""".format(vm_entry.key)) vm_entry.declare_killed() diff --git a/host/virtualmachine.py b/host/virtualmachine.py index e9fb919..52bf7dc 100755 --- a/host/virtualmachine.py +++ b/host/virtualmachine.py @@ -11,9 +11,9 @@ import tempfile import time from functools import wraps -from os.path import join as join_path from string import Template from typing import Union +from os.path import join as join_path import bitmath import sshtunnel @@ -49,7 +49,7 @@ def create_dev(script, _id, dev, ip=None): return output.decode("utf-8").strip() -def create_vxlan_br_tap(_id, _dev, ip=None): +def create_vxlan_br_tap(_id, _dev, tap_id, ip=None): network_script_base = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'network') vxlan = create_dev(script=os.path.join(network_script_base, 'create-vxlan.sh'), _id=_id, dev=_dev) @@ -58,7 +58,7 @@ def create_vxlan_br_tap(_id, _dev, ip=None): _id=_id, dev=vxlan, ip=ip) if bridge: tap = create_dev(script=os.path.join(network_script_base, 'create-tap.sh'), - _id=str(random.randint(1, 100000)), dev=bridge) + _id=str(tap_id), dev=bridge) if tap: return tap @@ -108,6 +108,7 @@ def update_radvd_conf(etcd_client): except Exception: sp.check_output(['service', 'radvd', 'restart']) + def get_start_command_args(vm_entry, vnc_sock_filename: str, migration=False, migration_port=None): threads_per_core = 1 vm_memory = int(bitmath.parse_string_unsafe(vm_entry.specs["ram"]).to_MB()) @@ -129,8 +130,8 @@ def get_start_command_args(vm_entry, vnc_sock_filename: str, migration=False, mi command += " -incoming tcp:[::]:{}".format(migration_port) tap = None - for network_and_mac in vm_networks: - network_name, mac = network_and_mac + for network_mac_and_tap in vm_networks: + network_name, mac, tap = network_mac_and_tap _key = os.path.join(env_vars.get('NETWORK_PREFIX'), vm_entry.owner, network_name) network = etcd_client.get(_key, value_in_json=True) @@ -139,7 +140,10 @@ def get_start_command_args(vm_entry, vnc_sock_filename: str, migration=False, mi network_ipv6 = network.value["ipv6"] if network_type == "vxlan": - tap = create_vxlan_br_tap(network_id, env_vars.get("VXLAN_PHY_DEV"), network_ipv6) + tap = create_vxlan_br_tap(_id=network_id, + _dev=env_vars.get("VXLAN_PHY_DEV"), + tap_id=tap, + ip=network_ipv6) update_radvd_conf(etcd_client) command += " -netdev tap,id=vmnet{net_id},ifname={tap},script=no,downscript=no" \ @@ -237,11 +241,13 @@ def delete(vm_entry): logger.info("Deleting VM | %s", vm_entry) stop(vm_entry) - r_status = image_storage_handler.delete_vm_image(vm_entry.uuid) - if r_status: + if image_storage_handler.is_vm_image_exists(vm_entry.uuid): + r_status = image_storage_handler.delete_vm_image(vm_entry.uuid) + if r_status: + etcd_client.client.delete(vm_entry.key) + else: etcd_client.client.delete(vm_entry.key) - def transfer(request_event): # This function would run on source host i.e host on which the vm # is running initially. This host would be responsible for transferring From 1e7300b56e5d8d22527d860f3f4a5a5635426a43 Mon Sep 17 00:00:00 2001 From: meow Date: Tue, 3 Dec 2019 15:40:41 +0500 Subject: [PATCH 026/284] Efforts to make ucloud a python package --- .gitignore | 6 +- Pipfile | 26 - Pipfile.lock | 936 ------------------ README.md | 9 + ucloud.py => bin/ucloud | 16 +- scheduler/tests/__init__.py | 0 setup.py | 35 + __init__.py => ucloud/__init__.py | 0 {api => ucloud/api}/README.md | 0 {api => ucloud/api}/__init__.py | 0 {api => ucloud/api}/common_fields.py | 2 +- {api => ucloud/api}/create_image_store.py | 2 +- {api => ucloud/api}/helper.py | 12 +- {api => ucloud/api}/main.py | 14 +- {api => ucloud/api}/schemas.py | 6 +- {filescanner => ucloud/common}/__init__.py | 0 {common => ucloud/common}/classes.py | 0 {common => ucloud/common}/counters.py | 0 {common => ucloud/common}/helpers.py | 0 {common => ucloud/common}/host.py | 0 {common => ucloud/common}/request.py | 0 {common => ucloud/common}/storage_handlers.py | 2 +- {common => ucloud/common}/vm.py | 0 config.py => ucloud/config.py | 8 +- {docs => ucloud/docs}/Makefile | 0 {common => ucloud/docs}/__init__.py | 0 {docs => ucloud/docs/source}/__init__.py | 0 {docs => ucloud/docs}/source/conf.py | 0 .../docs}/source/diagram-code/ucloud | 0 .../docs}/source/images/ucloud.svg | 0 {docs => ucloud/docs}/source/index.rst | 0 .../source/introduction/installation.rst | 136 ++- .../source/introduction/introduction.rst | 5 +- {docs => ucloud/docs}/source/misc/todo.rst | 0 .../docs}/source/theory/summary.rst | 4 +- .../installation-troubleshooting.rst | 0 .../how-to-create-an-os-image-for-ucloud.rst | 0 .../docs}/source/usage/usage-for-admins.rst | 4 +- .../docs}/source/usage/usage-for-users.rst | 8 +- {host => ucloud/filescanner}/__init__.py | 0 {filescanner => ucloud/filescanner}/main.py | 4 +- {hack => ucloud/hack}/README.org | 0 {hack => ucloud/hack}/conf.d/ucloud-host | 0 {hack => ucloud/hack}/nftables.conf | 0 {hack => ucloud/hack}/rc-scripts/ucloud-api | 0 {hack => ucloud/hack}/rc-scripts/ucloud-host | 0 ucloud/hack/rc-scripts/ucloud-metadata | 8 + .../hack}/rc-scripts/ucloud-scheduler | 0 {imagescanner => ucloud/host}/__init__.py | 0 {host => ucloud/host}/helper.py | 0 {host => ucloud/host}/main.py | 4 +- {host => ucloud/host}/qmp/__init__.py | 0 {host => ucloud/host}/qmp/qmp.py | 0 {host => ucloud/host}/virtualmachine.py | 8 +- ucloud/imagescanner/__init__.py | 3 + {imagescanner => ucloud/imagescanner}/main.py | 4 +- {docs/source => ucloud/metadata}/__init__.py | 0 {metadata => ucloud/metadata}/main.py | 2 +- {network => ucloud/network}/README | 0 {metadata => ucloud/network}/__init__.py | 0 {network => ucloud/network}/create-bridge.sh | 0 {network => ucloud/network}/create-tap.sh | 0 {network => ucloud/network}/create-vxlan.sh | 0 .../network}/radvd-template.conf | 0 sanity_checks.py => ucloud/sanity_checks.py | 2 +- {scheduler => ucloud/scheduler}/__init__.py | 0 {scheduler => ucloud/scheduler}/helper.py | 8 +- {scheduler => ucloud/scheduler}/main.py | 8 +- .../scheduler/tests}/__init__.py | 0 .../scheduler}/tests/test_basics.py | 2 +- .../tests/test_dead_host_mechanism.py | 0 71 files changed, 241 insertions(+), 1043 deletions(-) delete mode 100644 Pipfile delete mode 100644 Pipfile.lock create mode 100644 README.md rename ucloud.py => bin/ucloud (78%) delete mode 100644 scheduler/tests/__init__.py create mode 100644 setup.py rename __init__.py => ucloud/__init__.py (100%) rename {api => ucloud/api}/README.md (100%) rename {api => ucloud/api}/__init__.py (100%) rename {api => ucloud/api}/common_fields.py (96%) rename {api => ucloud/api}/create_image_store.py (87%) rename {api => ucloud/api}/helper.py (94%) rename {api => ucloud/api}/main.py (97%) rename {api => ucloud/api}/schemas.py (99%) rename {filescanner => ucloud/common}/__init__.py (100%) rename {common => ucloud/common}/classes.py (100%) rename {common => ucloud/common}/counters.py (100%) rename {common => ucloud/common}/helpers.py (100%) rename {common => ucloud/common}/host.py (100%) rename {common => ucloud/common}/request.py (100%) rename {common => ucloud/common}/storage_handlers.py (99%) rename {common => ucloud/common}/vm.py (100%) rename config.py => ucloud/config.py (83%) rename {docs => ucloud/docs}/Makefile (100%) rename {common => ucloud/docs}/__init__.py (100%) rename {docs => ucloud/docs/source}/__init__.py (100%) rename {docs => ucloud/docs}/source/conf.py (100%) rename {docs => ucloud/docs}/source/diagram-code/ucloud (100%) rename {docs => ucloud/docs}/source/images/ucloud.svg (100%) rename {docs => ucloud/docs}/source/index.rst (100%) rename {docs => ucloud/docs}/source/introduction/installation.rst (55%) rename {docs => ucloud/docs}/source/introduction/introduction.rst (86%) rename {docs => ucloud/docs}/source/misc/todo.rst (100%) rename {docs => ucloud/docs}/source/theory/summary.rst (98%) rename {docs => ucloud/docs}/source/troubleshooting/installation-troubleshooting.rst (100%) rename {docs => ucloud/docs}/source/usage/how-to-create-an-os-image-for-ucloud.rst (100%) rename {docs => ucloud/docs}/source/usage/usage-for-admins.rst (96%) rename {docs => ucloud/docs}/source/usage/usage-for-users.rst (91%) rename {host => ucloud/filescanner}/__init__.py (100%) rename {filescanner => ucloud/filescanner}/main.py (97%) rename {hack => ucloud/hack}/README.org (100%) rename {hack => ucloud/hack}/conf.d/ucloud-host (100%) rename {hack => ucloud/hack}/nftables.conf (100%) rename {hack => ucloud/hack}/rc-scripts/ucloud-api (100%) rename {hack => ucloud/hack}/rc-scripts/ucloud-host (100%) create mode 100644 ucloud/hack/rc-scripts/ucloud-metadata rename {hack => ucloud/hack}/rc-scripts/ucloud-scheduler (100%) rename {imagescanner => ucloud/host}/__init__.py (100%) rename {host => ucloud/host}/helper.py (100%) rename {host => ucloud/host}/main.py (98%) rename {host => ucloud/host}/qmp/__init__.py (100%) rename {host => ucloud/host}/qmp/qmp.py (100%) rename {host => ucloud/host}/virtualmachine.py (97%) create mode 100644 ucloud/imagescanner/__init__.py rename {imagescanner => ucloud/imagescanner}/main.py (96%) rename {docs/source => ucloud/metadata}/__init__.py (100%) rename {metadata => ucloud/metadata}/main.py (98%) rename {network => ucloud/network}/README (100%) rename {metadata => ucloud/network}/__init__.py (100%) rename {network => ucloud/network}/create-bridge.sh (100%) rename {network => ucloud/network}/create-tap.sh (100%) rename {network => ucloud/network}/create-vxlan.sh (100%) rename {network => ucloud/network}/radvd-template.conf (100%) rename sanity_checks.py => ucloud/sanity_checks.py (95%) rename {scheduler => ucloud/scheduler}/__init__.py (100%) rename {scheduler => ucloud/scheduler}/helper.py (94%) rename {scheduler => ucloud/scheduler}/main.py (95%) rename {network => ucloud/scheduler/tests}/__init__.py (100%) rename {scheduler => ucloud/scheduler}/tests/test_basics.py (99%) rename {scheduler => ucloud/scheduler}/tests/test_dead_host_mechanism.py (100%) diff --git a/.gitignore b/.gitignore index 55adfaf..8cd5f99 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,7 @@ __pycache__ -docs/build -logs.txt \ No newline at end of file +ucloud/docs/build +logs.txt + +ucloud.egg-info \ No newline at end of file diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 63c2610..0000000 --- a/Pipfile +++ /dev/null @@ -1,26 +0,0 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[dev-packages] -prospector = {extras = ["with_everything"],version = "*"} -pylama = "*" - -[packages] -python-decouple = "*" -requests = "*" -flask = "*" -flask-restful = "*" -bitmath = "*" -etcd3-wrapper = {editable = true,git = "git+https://code.ungleich.ch/ungleich-public/etcd3_wrapper.git",ref = "wip"} -python-etcd3 = {editable = true,git = "git+https://github.com/kragniz/python-etcd3.git"} -pyotp = "*" -sshtunnel = "*" -helper = "*" -sphinx = "*" -pynetbox = "*" -sphinx-rtd-theme = "*" - -[requires] -python_version = "3.5" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 5853a4b..0000000 --- a/Pipfile.lock +++ /dev/null @@ -1,936 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "7f5bc76f02cef7e98fa631f1954b2b7afa46a7796650386b91c9a6c591945f75" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.5" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "alabaster": { - "hashes": [ - "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", - "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02" - ], - "version": "==0.7.12" - }, - "aniso8601": { - "hashes": [ - "sha256:529dcb1f5f26ee0df6c0a1ee84b7b27197c3c50fc3a6321d66c544689237d072", - "sha256:c033f63d028b9a58e3ab0c2c7d0532ab4bfa7452bfc788fbfe3ddabd327b181a" - ], - "version": "==8.0.0" - }, - "babel": { - "hashes": [ - "sha256:af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab", - "sha256:e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28" - ], - "version": "==2.7.0" - }, - "bcrypt": { - "hashes": [ - "sha256:0258f143f3de96b7c14f762c770f5fc56ccd72f8a1857a451c1cd9a655d9ac89", - "sha256:0b0069c752ec14172c5f78208f1863d7ad6755a6fae6fe76ec2c80d13be41e42", - "sha256:19a4b72a6ae5bb467fea018b825f0a7d917789bcfe893e53f15c92805d187294", - "sha256:5432dd7b34107ae8ed6c10a71b4397f1c853bd39a4d6ffa7e35f40584cffd161", - "sha256:6305557019906466fc42dbc53b46da004e72fd7a551c044a827e572c82191752", - "sha256:69361315039878c0680be456640f8705d76cb4a3a3fe1e057e0f261b74be4b31", - "sha256:6fe49a60b25b584e2f4ef175b29d3a83ba63b3a4df1b4c0605b826668d1b6be5", - "sha256:74a015102e877d0ccd02cdeaa18b32aa7273746914a6c5d0456dd442cb65b99c", - "sha256:763669a367869786bb4c8fcf731f4175775a5b43f070f50f46f0b59da45375d0", - "sha256:8b10acde4e1919d6015e1df86d4c217d3b5b01bb7744c36113ea43d529e1c3de", - "sha256:9fe92406c857409b70a38729dbdf6578caf9228de0aef5bc44f859ffe971a39e", - "sha256:a190f2a5dbbdbff4b74e3103cef44344bc30e61255beb27310e2aec407766052", - "sha256:a595c12c618119255c90deb4b046e1ca3bcfad64667c43d1166f2b04bc72db09", - "sha256:c9457fa5c121e94a58d6505cadca8bed1c64444b83b3204928a866ca2e599105", - "sha256:cb93f6b2ab0f6853550b74e051d297c27a638719753eb9ff66d1e4072be67133", - "sha256:ce4e4f0deb51d38b1611a27f330426154f2980e66582dc5f438aad38b5f24fc1", - "sha256:d7bdc26475679dd073ba0ed2766445bb5b20ca4793ca0db32b399dccc6bc84b7", - "sha256:ff032765bb8716d9387fd5376d987a937254b0619eff0972779515b5c98820bc" - ], - "version": "==3.1.7" - }, - "bitmath": { - "hashes": [ - "sha256:293325f01e65defe966853111df11d39215eb705a967cb115851da8c4cfa3eb8" - ], - "index": "pypi", - "version": "==1.3.3.1" - }, - "certifi": { - "hashes": [ - "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", - "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef" - ], - "version": "==2019.9.11" - }, - "cffi": { - "hashes": [ - "sha256:0b49274afc941c626b605fb59b59c3485c17dc776dc3cc7cc14aca74cc19cc42", - "sha256:0e3ea92942cb1168e38c05c1d56b0527ce31f1a370f6117f1d490b8dcd6b3a04", - "sha256:135f69aecbf4517d5b3d6429207b2dff49c876be724ac0c8bf8e1ea99df3d7e5", - "sha256:19db0cdd6e516f13329cba4903368bff9bb5a9331d3410b1b448daaadc495e54", - "sha256:2781e9ad0e9d47173c0093321bb5435a9dfae0ed6a762aabafa13108f5f7b2ba", - "sha256:291f7c42e21d72144bb1c1b2e825ec60f46d0a7468f5346841860454c7aa8f57", - "sha256:2c5e309ec482556397cb21ede0350c5e82f0eb2621de04b2633588d118da4396", - "sha256:2e9c80a8c3344a92cb04661115898a9129c074f7ab82011ef4b612f645939f12", - "sha256:32a262e2b90ffcfdd97c7a5e24a6012a43c61f1f5a57789ad80af1d26c6acd97", - "sha256:3c9fff570f13480b201e9ab69453108f6d98244a7f495e91b6c654a47486ba43", - "sha256:415bdc7ca8c1c634a6d7163d43fb0ea885a07e9618a64bda407e04b04333b7db", - "sha256:42194f54c11abc8583417a7cf4eaff544ce0de8187abaf5d29029c91b1725ad3", - "sha256:4424e42199e86b21fc4db83bd76909a6fc2a2aefb352cb5414833c030f6ed71b", - "sha256:4a43c91840bda5f55249413037b7a9b79c90b1184ed504883b72c4df70778579", - "sha256:599a1e8ff057ac530c9ad1778293c665cb81a791421f46922d80a86473c13346", - "sha256:5c4fae4e9cdd18c82ba3a134be256e98dc0596af1e7285a3d2602c97dcfa5159", - "sha256:5ecfa867dea6fabe2a58f03ac9186ea64da1386af2159196da51c4904e11d652", - "sha256:62f2578358d3a92e4ab2d830cd1c2049c9c0d0e6d3c58322993cc341bdeac22e", - "sha256:6471a82d5abea994e38d2c2abc77164b4f7fbaaf80261cb98394d5793f11b12a", - "sha256:6d4f18483d040e18546108eb13b1dfa1000a089bcf8529e30346116ea6240506", - "sha256:71a608532ab3bd26223c8d841dde43f3516aa5d2bf37b50ac410bb5e99053e8f", - "sha256:74a1d8c85fb6ff0b30fbfa8ad0ac23cd601a138f7509dc617ebc65ef305bb98d", - "sha256:7b93a885bb13073afb0aa73ad82059a4c41f4b7d8eb8368980448b52d4c7dc2c", - "sha256:7d4751da932caaec419d514eaa4215eaf14b612cff66398dd51129ac22680b20", - "sha256:7f627141a26b551bdebbc4855c1157feeef18241b4b8366ed22a5c7d672ef858", - "sha256:8169cf44dd8f9071b2b9248c35fc35e8677451c52f795daa2bb4643f32a540bc", - "sha256:aa00d66c0fab27373ae44ae26a66a9e43ff2a678bf63a9c7c1a9a4d61172827a", - "sha256:ccb032fda0873254380aa2bfad2582aedc2959186cce61e3a17abc1a55ff89c3", - "sha256:d754f39e0d1603b5b24a7f8484b22d2904fa551fe865fd0d4c3332f078d20d4e", - "sha256:d75c461e20e29afc0aee7172a0950157c704ff0dd51613506bd7d82b718e7410", - "sha256:dcd65317dd15bc0451f3e01c80da2216a31916bdcffd6221ca1202d96584aa25", - "sha256:e570d3ab32e2c2861c4ebe6ffcad6a8abf9347432a37608fe1fbd157b3f0036b", - "sha256:fd43a88e045cf992ed09fa724b5315b790525f2676883a6ea64e3263bae6549d" - ], - "version": "==1.13.2" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "click": { - "hashes": [ - "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", - "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" - ], - "version": "==7.0" - }, - "cryptography": { - "hashes": [ - "sha256:02079a6addc7b5140ba0825f542c0869ff4df9a69c360e339ecead5baefa843c", - "sha256:1df22371fbf2004c6f64e927668734070a8953362cd8370ddd336774d6743595", - "sha256:369d2346db5934345787451504853ad9d342d7f721ae82d098083e1f49a582ad", - "sha256:3cda1f0ed8747339bbdf71b9f38ca74c7b592f24f65cdb3ab3765e4b02871651", - "sha256:44ff04138935882fef7c686878e1c8fd80a723161ad6a98da31e14b7553170c2", - "sha256:4b1030728872c59687badcca1e225a9103440e467c17d6d1730ab3d2d64bfeff", - "sha256:58363dbd966afb4f89b3b11dfb8ff200058fbc3b947507675c19ceb46104b48d", - "sha256:6ec280fb24d27e3d97aa731e16207d58bd8ae94ef6eab97249a2afe4ba643d42", - "sha256:7270a6c29199adc1297776937a05b59720e8a782531f1f122f2eb8467f9aab4d", - "sha256:73fd30c57fa2d0a1d7a49c561c40c2f79c7d6c374cc7750e9ac7c99176f6428e", - "sha256:7f09806ed4fbea8f51585231ba742b58cbcfbfe823ea197d8c89a5e433c7e912", - "sha256:90df0cc93e1f8d2fba8365fb59a858f51a11a394d64dbf3ef844f783844cc793", - "sha256:971221ed40f058f5662a604bd1ae6e4521d84e6cad0b7b170564cc34169c8f13", - "sha256:a518c153a2b5ed6b8cc03f7ae79d5ffad7315ad4569b2d5333a13c38d64bd8d7", - "sha256:b0de590a8b0979649ebeef8bb9f54394d3a41f66c5584fff4220901739b6b2f0", - "sha256:b43f53f29816ba1db8525f006fa6f49292e9b029554b3eb56a189a70f2a40879", - "sha256:d31402aad60ed889c7e57934a03477b572a03af7794fa8fb1780f21ea8f6551f", - "sha256:de96157ec73458a7f14e3d26f17f8128c959084931e8997b9e655a39c8fde9f9", - "sha256:df6b4dca2e11865e6cfbfb708e800efb18370f5a46fd601d3755bc7f85b3a8a2", - "sha256:ecadccc7ba52193963c0475ac9f6fa28ac01e01349a2ca48509667ef41ffd2cf", - "sha256:fb81c17e0ebe3358486cd8cc3ad78adbae58af12fc2bf2bc0bb84e8090fa5ce8" - ], - "version": "==2.8" - }, - "docutils": { - "hashes": [ - "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0", - "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827", - "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99" - ], - "version": "==0.15.2" - }, - "etcd3-wrapper": { - "editable": true, - "git": "https://code.ungleich.ch/ungleich-public/etcd3_wrapper.git", - "ref": "76fb0bdf797199e9ea161dad1d004eea9b4520f8" - }, - "flask": { - "hashes": [ - "sha256:13f9f196f330c7c2c5d7a5cf91af894110ca0215ac051b5844701f2bfd934d52", - "sha256:45eb5a6fd193d6cf7e0cf5d8a5b31f83d5faae0293695626f539a823e93b13f6" - ], - "index": "pypi", - "version": "==1.1.1" - }, - "flask-restful": { - "hashes": [ - "sha256:ecd620c5cc29f663627f99e04f17d1f16d095c83dc1d618426e2ad68b03092f8", - "sha256:f8240ec12349afe8df1db168ea7c336c4e5b0271a36982bff7394f93275f2ca9" - ], - "index": "pypi", - "version": "==0.3.7" - }, - "grpcio": { - "hashes": [ - "sha256:0419ae5a45f49c7c40d9ae77ae4de9442431b7822851dfbbe56ee0eacb5e5654", - "sha256:1e8631eeee0fb0b4230aeb135e4890035f6ef9159c2a3555fa184468e325691a", - "sha256:24db2fa5438f3815a4edb7a189035051760ca6aa2b0b70a6a948b28bfc63c76b", - "sha256:2adb1cdb7d33e91069517b41249622710a94a1faece1fed31cd36904e4201cde", - "sha256:2cd51f35692b551aeb1fdeb7a256c7c558f6d78fcddff00640942d42f7aeba5f", - "sha256:3247834d24964589f8c2b121b40cd61319b3c2e8d744a6a82008643ef8a378b1", - "sha256:3433cb848b4209717722b62392e575a77a52a34d67c6730138102abc0a441685", - "sha256:39671b7ff77a962bd745746d9d2292c8ed227c5748f16598d16d8631d17dd7e5", - "sha256:40a0b8b2e6f6dd630f8b267eede2f40a848963d0f3c40b1b1f453a4a870f679e", - "sha256:40f9a74c7aa210b3e76eb1c9d56aa8d08722b73426a77626967019df9bbac287", - "sha256:423f76aa504c84cb94594fb88b8a24027c887f1c488cf58f2173f22f4fbd046c", - "sha256:43bd04cec72281a96eb361e1b0232f0f542b46da50bcfe72ef7e5a1b41d00cb3", - "sha256:43e38762635c09e24885d15e3a8e374b72d105d4178ee2cc9491855a8da9c380", - "sha256:4413b11c2385180d7de03add6c8845dd66692b148d36e27ec8c9ef537b2553a1", - "sha256:4450352a87094fd58daf468b04c65a9fa19ad11a0ac8ac7b7ff17d46f873cbc1", - "sha256:49ffda04a6e44de028b3b786278ac9a70043e7905c3eea29eed88b6524d53a29", - "sha256:4a38c4dde4c9120deef43aaabaa44f19186c98659ce554c29788c4071ab2f0a4", - "sha256:50b1febdfd21e2144b56a9aa226829e93a79c354ef22a4e5b013d9965e1ec0ed", - "sha256:559b1a3a8be7395ded2943ea6c2135d096f8cc7039d6d12127110b6496f251fe", - "sha256:5de86c182667ec68cf84019aa0d8ceccf01d352cdca19bf9e373725204bdbf50", - "sha256:5fc069bb481fe3fad0ba24d3baaf69e22dfa6cc1b63290e6dfeaf4ac1e996fb7", - "sha256:6a19d654da49516296515d6f65de4bbcbd734bc57913b21a610cfc45e6df3ff1", - "sha256:7535b3e52f498270e7877dde1c8944d6b7720e93e2e66b89c82a11447b5818f5", - "sha256:7c4e495bcabc308198b8962e60ca12f53b27eb8f03a21ac1d2d711d6dd9ecfca", - "sha256:8a8fc4a0220367cb8370cedac02272d574079ccc32bffbb34d53aaf9e38b5060", - "sha256:8b008515e067232838daca020d1af628bf6520c8cc338bf383284efe6d8bd083", - "sha256:8d1684258e1385e459418f3429e107eec5fb3d75e1f5a8c52e5946b3f329d6ea", - "sha256:8eb5d54b87fb561dc2e00a5c5226c33ffe8dbc13f2e4033a412bafb7b37b194d", - "sha256:94cdef0c61bd014bb7af495e21a1c3a369dd0399c3cd1965b1502043f5c88d94", - "sha256:9d9f3be69c7a5e84c3549a8c4403fa9ac7672da456863d21e390b2bbf45ccad1", - "sha256:9fb6fb5975a448169756da2d124a1beb38c0924ff6c0306d883b6848a9980f38", - "sha256:a5eaae8700b87144d7dfb475aa4675e500ff707292caba3deff41609ddc5b845", - "sha256:aaeac2d552772b76d24eaff67a5d2325bc5205c74c0d4f9fbe71685d4a971db2", - "sha256:bb611e447559b3b5665e12a7da5160c0de6876097f62bf1d23ba66911564868e", - "sha256:bc0d41f4eb07da8b8d3ea85e50b62f6491ab313834db86ae2345be07536a4e5a", - "sha256:bf51051c129b847d1bb63a9b0826346b5f52fb821b15fe5e0d5ef86f268510f5", - "sha256:c948c034d8997526011960db54f512756fb0b4be1b81140a15b4ef094c6594a4", - "sha256:d435a01334157c3b126b4ee5141401d44bdc8440993b18b05e2f267a6647f92d", - "sha256:d46c1f95672b73288e08cdca181e14e84c6229b5879561b7b8cfd48374e09287", - "sha256:d5d58309b42064228b16b0311ff715d6c6e20230e81b35e8d0c8cfa1bbdecad8", - "sha256:dc6e2e91365a1dd6314d615d80291159c7981928b88a4c65654e3fefac83a836", - "sha256:e0dfb5f7a39029a6cbec23affa923b22a2c02207960fd66f109e01d6f632c1eb", - "sha256:eb4bf58d381b1373bd21d50837a53953d625d1693f1b58fed12743c75d3dd321", - "sha256:ebb211a85248dbc396b29320273c1ffde484b898852432613e8df0164c091006", - "sha256:ec759ece4786ae993a5b7dc3b3dead6e9375d89a6c65dfd6860076d2eb2abe7b", - "sha256:f55108397a8fa164268238c3e69cc134e945d1f693572a2f05a028b8d0d2b837", - "sha256:f6c706866d424ff285b85a02de7bbe5ed0ace227766b2c42cbe12f3d9ea5a8aa", - "sha256:f8370ad332b36fbad117440faf0dd4b910e80b9c49db5648afd337abdde9a1b6" - ], - "version": "==1.25.0" - }, - "helper": { - "hashes": [ - "sha256:33d4a58046018fea9f46da5835a768feb9beab3528d4025d063bf354c4a19750", - "sha256:a63d4a9255ad5071043e7e4ab8000a512627f1db958b1941b63c7d75e56ea65c" - ], - "index": "pypi", - "version": "==2.4.2" - }, - "idna": { - "hashes": [ - "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", - "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" - ], - "version": "==2.8" - }, - "imagesize": { - "hashes": [ - "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8", - "sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5" - ], - "version": "==1.1.0" - }, - "itsdangerous": { - "hashes": [ - "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", - "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" - ], - "version": "==1.1.0" - }, - "jinja2": { - "hashes": [ - "sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f", - "sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de" - ], - "version": "==2.10.3" - }, - "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" - }, - "packaging": { - "hashes": [ - "sha256:28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47", - "sha256:d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108" - ], - "version": "==19.2" - }, - "paramiko": { - "hashes": [ - "sha256:99f0179bdc176281d21961a003ffdb2ec369daac1a1007241f53374e376576cf", - "sha256:f4b2edfa0d226b70bd4ca31ea7e389325990283da23465d572ed1f70a7583041" - ], - "version": "==2.6.0" - }, - "protobuf": { - "hashes": [ - "sha256:125713564d8cfed7610e52444c9769b8dcb0b55e25cc7841f2290ee7bc86636f", - "sha256:1accdb7a47e51503be64d9a57543964ba674edac103215576399d2d0e34eac77", - "sha256:27003d12d4f68e3cbea9eb67427cab3bfddd47ff90670cb367fcd7a3a89b9657", - "sha256:3264f3c431a631b0b31e9db2ae8c927b79fc1a7b1b06b31e8e5bcf2af91fe896", - "sha256:3c5ab0f5c71ca5af27143e60613729e3488bb45f6d3f143dc918a20af8bab0bf", - "sha256:45dcf8758873e3f69feab075e5f3177270739f146255225474ee0b90429adef6", - "sha256:56a77d61a91186cc5676d8e11b36a5feb513873e4ae88d2ee5cf530d52bbcd3b", - "sha256:5984e4947bbcef5bd849d6244aec507d31786f2dd3344139adc1489fb403b300", - "sha256:6b0441da73796dd00821763bb4119674eaf252776beb50ae3883bed179a60b2a", - "sha256:6f6677c5ade94d4fe75a912926d6796d5c71a2a90c2aeefe0d6f211d75c74789", - "sha256:84a825a9418d7196e2acc48f8746cf1ee75877ed2f30433ab92a133f3eaf8fbe", - "sha256:b842c34fe043ccf78b4a6cf1019d7b80113707d68c88842d061fa2b8fb6ddedc", - "sha256:ca33d2f09dae149a1dcf942d2d825ebb06343b77b437198c9e2ef115cf5d5bc1", - "sha256:db83b5c12c0cd30150bb568e6feb2435c49ce4e68fe2d7b903113f0e221e58fe", - "sha256:f50f3b1c5c1c1334ca7ce9cad5992f098f460ffd6388a3cabad10b66c2006b09", - "sha256:f99f127909731cafb841c52f9216e447d3e4afb99b17bebfad327a75aee206de" - ], - "version": "==3.10.0" - }, - "pycparser": { - "hashes": [ - "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" - ], - "version": "==2.19" - }, - "pygments": { - "hashes": [ - "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", - "sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297" - ], - "version": "==2.4.2" - }, - "pynacl": { - "hashes": [ - "sha256:05c26f93964373fc0abe332676cb6735f0ecad27711035b9472751faa8521255", - "sha256:0c6100edd16fefd1557da078c7a31e7b7d7a52ce39fdca2bec29d4f7b6e7600c", - "sha256:0d0a8171a68edf51add1e73d2159c4bc19fc0718e79dec51166e940856c2f28e", - "sha256:1c780712b206317a746ace34c209b8c29dbfd841dfbc02aa27f2084dd3db77ae", - "sha256:2424c8b9f41aa65bbdbd7a64e73a7450ebb4aa9ddedc6a081e7afcc4c97f7621", - "sha256:2d23c04e8d709444220557ae48ed01f3f1086439f12dbf11976e849a4926db56", - "sha256:30f36a9c70450c7878053fa1344aca0145fd47d845270b43a7ee9192a051bf39", - "sha256:37aa336a317209f1bb099ad177fef0da45be36a2aa664507c5d72015f956c310", - "sha256:4943decfc5b905748f0756fdd99d4f9498d7064815c4cf3643820c9028b711d1", - "sha256:53126cd91356342dcae7e209f840212a58dcf1177ad52c1d938d428eebc9fee5", - "sha256:57ef38a65056e7800859e5ba9e6091053cd06e1038983016effaffe0efcd594a", - "sha256:5bd61e9b44c543016ce1f6aef48606280e45f892a928ca7068fba30021e9b786", - "sha256:6482d3017a0c0327a49dddc8bd1074cc730d45db2ccb09c3bac1f8f32d1eb61b", - "sha256:7d3ce02c0784b7cbcc771a2da6ea51f87e8716004512493a2b69016326301c3b", - "sha256:a14e499c0f5955dcc3991f785f3f8e2130ed504fa3a7f44009ff458ad6bdd17f", - "sha256:a39f54ccbcd2757d1d63b0ec00a00980c0b382c62865b61a505163943624ab20", - "sha256:aabb0c5232910a20eec8563503c153a8e78bbf5459490c49ab31f6adf3f3a415", - "sha256:bd4ecb473a96ad0f90c20acba4f0bf0df91a4e03a1f4dd6a4bdc9ca75aa3a715", - "sha256:bf459128feb543cfca16a95f8da31e2e65e4c5257d2f3dfa8c0c1031139c9c92", - "sha256:e2da3c13307eac601f3de04887624939aca8ee3c9488a0bb0eca4fb9401fc6b1", - "sha256:f67814c38162f4deb31f68d590771a29d5ae3b1bd64b75cf232308e5c74777e0" - ], - "version": "==1.3.0" - }, - "pynetbox": { - "hashes": [ - "sha256:7c2282891ab1d3a5f5b28cb3b83c30d33c7ac3da1ee928c7332a4d2fac32f283" - ], - "index": "pypi", - "version": "==4.2.0" - }, - "pyotp": { - "hashes": [ - "sha256:c88f37fd47541a580b744b42136f387cdad481b560ef410c0d85c957eb2a2bc0", - "sha256:fc537e8acd985c5cbf51e11b7d53c42276fee017a73aec7c07380695671ca1a1" - ], - "index": "pypi", - "version": "==2.3.0" - }, - "pyparsing": { - "hashes": [ - "sha256:20f995ecd72f2a1f4bf6b072b63b22e2eb457836601e76d6e5dfcd75436acc1f", - "sha256:4ca62001be367f01bd3e92ecbb79070272a9d4964dce6a48a82ff0b8bc7e683a" - ], - "version": "==2.4.5" - }, - "python-decouple": { - "hashes": [ - "sha256:55c546b85b0c47a15a47a4312d451a437f7344a9be3e001660bccd93b637de95" - ], - "index": "pypi", - "version": "==3.3" - }, - "python-etcd3": { - "editable": true, - "git": "https://github.com/kragniz/python-etcd3.git", - "ref": "247e3952d0b47324091a36ace3ad9717469fb6b9" - }, - "pytz": { - "hashes": [ - "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d", - "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be" - ], - "version": "==2019.3" - }, - "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" - }, - "requests": { - "hashes": [ - "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", - "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" - ], - "index": "pypi", - "version": "==2.22.0" - }, - "six": { - "hashes": [ - "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", - "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" - ], - "version": "==1.13.0" - }, - "snowballstemmer": { - "hashes": [ - "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", - "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" - ], - "version": "==2.0.0" - }, - "sphinx": { - "hashes": [ - "sha256:31088dfb95359384b1005619827eaee3056243798c62724fd3fa4b84ee4d71bd", - "sha256:52286a0b9d7caa31efee301ec4300dbdab23c3b05da1c9024b4e84896fb73d79" - ], - "index": "pypi", - "version": "==2.2.1" - }, - "sphinx-rtd-theme": { - "hashes": [ - "sha256:00cf895504a7895ee433807c62094cf1e95f065843bf3acd17037c3e9a2becd4", - "sha256:728607e34d60456d736cc7991fd236afb828b21b82f956c5ea75f94c8414040a" - ], - "index": "pypi", - "version": "==0.4.3" - }, - "sphinxcontrib-applehelp": { - "hashes": [ - "sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897", - "sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d" - ], - "version": "==1.0.1" - }, - "sphinxcontrib-devhelp": { - "hashes": [ - "sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34", - "sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981" - ], - "version": "==1.0.1" - }, - "sphinxcontrib-htmlhelp": { - "hashes": [ - "sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422", - "sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7" - ], - "version": "==1.0.2" - }, - "sphinxcontrib-jsmath": { - "hashes": [ - "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", - "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" - ], - "version": "==1.0.1" - }, - "sphinxcontrib-qthelp": { - "hashes": [ - "sha256:513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20", - "sha256:79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f" - ], - "version": "==1.0.2" - }, - "sphinxcontrib-serializinghtml": { - "hashes": [ - "sha256:c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227", - "sha256:db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768" - ], - "version": "==1.1.3" - }, - "sshtunnel": { - "hashes": [ - "sha256:c813fdcda8e81c3936ffeac47cb69cfb2d1f5e77ad0de656c6dab56aeebd9249" - ], - "index": "pypi", - "version": "==0.1.5" - }, - "tenacity": { - "hashes": [ - "sha256:72f397c2bb1887e048726603f3f629ea16f88cb3e61e4ed3c57e98582b8e3571", - "sha256:947e728aedf06e8db665bb7898112e90d17e48cc3f3289784a2b9ccf6e56fabc" - ], - "version": "==6.0.0" - }, - "urllib3": { - "hashes": [ - "sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293", - "sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745" - ], - "version": "==1.25.7" - }, - "werkzeug": { - "hashes": [ - "sha256:7280924747b5733b246fe23972186c6b348f9ae29724135a6dfc1e53cea433e7", - "sha256:e5f4a1f98b52b18a93da705a7458e55afb26f32bff83ff5d19189f92462d65c4" - ], - "version": "==0.16.0" - } - }, - "develop": { - "astroid": { - "hashes": [ - "sha256:6560e1e1749f68c64a4b5dee4e091fce798d2f0d84ebe638cf0e0585a343acf4", - "sha256:b65db1bbaac9f9f4d190199bb8680af6f6f84fd3769a5ea883df8a91fe68b4c4" - ], - "version": "==2.2.5" - }, - "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" - ], - "version": "==1.8.2" - }, - "docopt": { - "hashes": [ - "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491" - ], - "version": "==0.6.2" - }, - "docutils": { - "hashes": [ - "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0", - "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827", - "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99" - ], - "version": "==0.15.2" - }, - "dodgy": { - "hashes": [ - "sha256:65e13cf878d7aff129f1461c13cb5fd1bb6dfe66bb5327e09379c3877763280c" - ], - "version": "==0.1.9" - }, - "frosted": { - "hashes": [ - "sha256:c6a30ad502ea373f6fe4cafbcd896ece66948406b04365d14a3eb764cc529b07", - "sha256:d1e5d2b43a064b33c289b9a986a7425fd9a36bed8f519ca430ac7a0915e32b51" - ], - "version": "==1.4.1" - }, - "idna": { - "hashes": [ - "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", - "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" - ], - "version": "==2.8" - }, - "isort": { - "hashes": [ - "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", - "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd" - ], - "version": "==4.3.21" - }, - "lazy-object-proxy": { - "hashes": [ - "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d", - "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449", - "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08", - "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a", - "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50", - "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd", - "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239", - "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb", - "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea", - "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e", - "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156", - "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142", - "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442", - "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62", - "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db", - "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531", - "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383", - "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a", - "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357", - "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", - "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0" - ], - "version": "==1.4.3" - }, - "mccabe": { - "hashes": [ - "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", - "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" - ], - "version": "==0.6.1" - }, - "mock": { - "hashes": [ - "sha256:83657d894c90d5681d62155c82bda9c1187827525880eda8ff5df4ec813437c3", - "sha256:d157e52d4e5b938c550f39eb2fd15610db062441a9c2747d3dbfa9298211d0f8" - ], - "version": "==3.0.5" - }, - "mypy": { - "hashes": [ - "sha256:1521c186a3d200c399bd5573c828ea2db1362af7209b2adb1bb8532cea2fb36f", - "sha256:31a046ab040a84a0fc38bc93694876398e62bc9f35eca8ccbf6418b7297f4c00", - "sha256:3b1a411909c84b2ae9b8283b58b48541654b918e8513c20a400bb946aa9111ae", - "sha256:48c8bc99380575deb39f5d3400ebb6a8a1cb5cc669bbba4d3bb30f904e0a0e7d", - "sha256:540c9caa57a22d0d5d3c69047cc9dd0094d49782603eb03069821b41f9e970e9", - "sha256:672e418425d957e276c291930a3921b4a6413204f53fe7c37cad7bc57b9a3391", - "sha256:6ed3b9b3fdc7193ea7aca6f3c20549b377a56f28769783a8f27191903a54170f", - "sha256:9371290aa2cad5ad133e4cdc43892778efd13293406f7340b9ffe99d5ec7c1d9", - "sha256:ace6ac1d0f87d4072f05b5468a084a45b4eda970e4d26704f201e06d47ab2990", - "sha256:b428f883d2b3fe1d052c630642cc6afddd07d5cd7873da948644508be3b9d4a7", - "sha256:d5bf0e6ec8ba346a2cf35cb55bf4adfddbc6b6576fcc9e10863daa523e418dbb", - "sha256:d7574e283f83c08501607586b3167728c58e8442947e027d2d4c7dcd6d82f453", - "sha256:dc889c84241a857c263a2b1cd1121507db7d5b5f5e87e77147097230f374d10b", - "sha256:f4748697b349f373002656bf32fede706a0e713d67bfdcf04edf39b1f61d46eb" - ], - "version": "==0.740" - }, - "mypy-extensions": { - "hashes": [ - "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", - "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" - ], - "version": "==0.4.3" - }, - "nose": { - "hashes": [ - "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac", - "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a", - "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98" - ], - "version": "==1.3.7" - }, - "pep8-naming": { - "hashes": [ - "sha256:1b419fa45b68b61cd8c5daf4e0c96d28915ad14d3d5f35fcc1e7e95324a33a2e", - "sha256:4eedfd4c4b05e48796f74f5d8628c068ff788b9c2b08471ad408007fc6450e5a" - ], - "version": "==0.4.1" - }, - "pies": { - "hashes": [ - "sha256:79a652dddc64c6fa42c7dfe9686ae7b1d856391094b873e2f52fcd0bd662c102", - "sha256:e8a76923ce0e0f605240901983fe492814a65d3d803efe3013a0e1815b75e4e9" - ], - "version": "==2.6.7" - }, - "prospector": { - "extras": [ - "with_everything" - ], - "hashes": [ - "sha256:aba551e53dc1a5a432afa67385eaa81d7b4cf4c162dc1a4d0ee00b3a0712ad90" - ], - "index": "pypi", - "version": "==1.1.7" - }, - "pycodestyle": { - "hashes": [ - "sha256:cbc619d09254895b0d12c2c691e237b2e91e9b2ecf5e84c26b35400f93dcfb83", - "sha256:cbfca99bd594a10f674d0cd97a3d802a1fdef635d4361e1a2658de47ed261e3a" - ], - "version": "==2.4.0" - }, - "pydocstyle": { - "hashes": [ - "sha256:04c84e034ebb56eb6396c820442b8c4499ac5eb94a3bda88951ac3dc519b6058", - "sha256:66aff87ffe34b1e49bff2dd03a88ce6843be2f3346b0c9814410d34987fbab59" - ], - "version": "==4.0.1" - }, - "pyflakes": { - "hashes": [ - "sha256:08bd6a50edf8cffa9fa09a463063c425ecaaf10d1eb0335a7e8b1401aef89e6f", - "sha256:8d616a382f243dbf19b54743f280b80198be0bca3a5396f1d2e1fca6223e8805" - ], - "version": "==1.6.0" - }, - "pygments": { - "hashes": [ - "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", - "sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297" - ], - "version": "==2.4.2" - }, - "pylama": { - "hashes": [ - "sha256:9bae53ef9c1a431371d6a8dca406816a60d547147b60a4934721898f553b7d8f", - "sha256:fd61c11872d6256b019ef1235be37b77c922ef37ac9797df6bd489996dddeb15" - ], - "index": "pypi", - "version": "==7.7.1" - }, - "pylint": { - "hashes": [ - "sha256:5d77031694a5fb97ea95e828c8d10fc770a1df6eb3906067aaed42201a8a6a09", - "sha256:723e3db49555abaf9bf79dc474c6b9e2935ad82230b10c1138a71ea41ac0fff1" - ], - "version": "==2.3.1" - }, - "pylint-celery": { - "hashes": [ - "sha256:41e32094e7408d15c044178ea828dd524beedbdbe6f83f712c5e35bde1de4beb" - ], - "version": "==0.3" - }, - "pylint-django": { - "hashes": [ - "sha256:75c69d1ec2275918c37f175976da20e2f1e1e62e067098a685cd263ffa833dfd", - "sha256:c7cb6384ea7b33ea77052a5ae07358c10d377807390ef27b2e6ff997303fadb7" - ], - "version": "==2.0.10" - }, - "pylint-flask": { - "hashes": [ - "sha256:f4d97de2216bf7bfce07c9c08b166e978fe9f2725de2a50a9845a97de7e31517" - ], - "version": "==0.6" - }, - "pylint-plugin-utils": { - "hashes": [ - "sha256:2f30510e1c46edf268d3a195b2849bd98a1b9433229bb2ba63b8d776e1fc4d0a", - "sha256:57625dcca20140f43731311cd8fd879318bf45a8b0fd17020717a8781714a25a" - ], - "version": "==0.6" - }, - "pyroma": { - "hashes": [ - "sha256:351758a81e2a12c970deb73687e239636aad52795cd81429695073d59fff0699", - "sha256:c49c00377219626bf83df42adf018cc231e6162b68cc7aaf2ff1c63803924102" - ], - "version": "==2.6" - }, - "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" - }, - "requests": { - "hashes": [ - "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", - "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" - ], - "index": "pypi", - "version": "==2.22.0" - }, - "requirements-detector": { - "hashes": [ - "sha256:9fbc4b24e8b7c3663aff32e3eba34596848c6b91bd425079b386973bd8d08931" - ], - "version": "==0.6" - }, - "setoptconf": { - "hashes": [ - "sha256:5b0b5d8e0077713f5d5152d4f63be6f048d9a1bb66be15d089a11c898c3cf49c" - ], - "version": "==0.2.0" - }, - "six": { - "hashes": [ - "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", - "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" - ], - "version": "==1.13.0" - }, - "snowballstemmer": { - "hashes": [ - "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0", - "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52" - ], - "version": "==2.0.0" - }, - "typed-ast": { - "hashes": [ - "sha256:1170afa46a3799e18b4c977777ce137bb53c7485379d9706af8a59f2ea1aa161", - "sha256:18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", - "sha256:262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", - "sha256:2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", - "sha256:354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", - "sha256:48e5b1e71f25cfdef98b013263a88d7145879fbb2d5185f2a0c79fa7ebbeae47", - "sha256:4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", - "sha256:630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", - "sha256:66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", - "sha256:71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", - "sha256:7954560051331d003b4e2b3eb822d9dd2e376fa4f6d98fee32f452f52dd6ebb2", - "sha256:838997f4310012cf2e1ad3803bce2f3402e9ffb71ded61b5ee22617b3a7f6b6e", - "sha256:95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", - "sha256:bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", - "sha256:cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", - "sha256:d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", - "sha256:d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", - "sha256:d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", - "sha256:fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66", - "sha256:ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12" - ], - "markers": "implementation_name == 'cpython'", - "version": "==1.4.0" - }, - "typing-extensions": { - "hashes": [ - "sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2", - "sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d", - "sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575" - ], - "version": "==3.7.4.1" - }, - "urllib3": { - "hashes": [ - "sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293", - "sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745" - ], - "version": "==1.25.7" - }, - "vulture": { - "hashes": [ - "sha256:17be5f6a7c88ea43f2619f80338af7407275ee46a24000abe2570e59ca44b3d0", - "sha256:23d837cf619c3bb75f87bc498c79cd4f27f0c54031ca88a9e05606c9dd627fef" - ], - "version": "==0.24" - }, - "wrapt": { - "hashes": [ - "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1" - ], - "version": "==1.11.2" - } - } -} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b36fe90 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# ucloud + +**Open** + **Simple** + **Easy to hack** + **IPv6 First**. + +ucloud is an easy to use cloud management system. + +It is an alternative to OpenStack, OpenNebula or Cloudstack. + +ucloud is the first cloud management system that puts IPv6 first. ucloud also has an integral ordering process that we missed in existing solutions. \ No newline at end of file diff --git a/ucloud.py b/bin/ucloud similarity index 78% rename from ucloud.py rename to bin/ucloud index 28979b3..0d4309a 100644 --- a/ucloud.py +++ b/bin/ucloud @@ -1,9 +1,11 @@ +#!/usr/bin/env python3 + import argparse import multiprocessing as mp import logging from os.path import join as join_path -from sanity_checks import check +from ucloud.sanity_checks import check if __name__ == "__main__": arg_parser = argparse.ArgumentParser(prog='ucloud', @@ -26,29 +28,29 @@ if __name__ == "__main__": check() if args.component == 'api': - from api.main import main + from ucloud.api.main import main main() elif args.component == 'host': - from host.main import main + from ucloud.host.main import main hostname = args.component_args mp.set_start_method('spawn') main(*hostname) elif args.component == 'scheduler': - from scheduler.main import main + from ucloud.scheduler.main import main main() elif args.component == 'filescanner': - from filescanner.main import main + from ucloud.filescanner.main import main main() elif args.component == 'imagescanner': - from imagescanner.main import main + from ucloud.imagescanner.main import main main() elif args.component == 'metadata': - from metadata.main import main + from ucloud.metadata.main import main main() diff --git a/scheduler/tests/__init__.py b/scheduler/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..88b85e5 --- /dev/null +++ b/setup.py @@ -0,0 +1,35 @@ +from setuptools import setup, find_packages + +with open("README.md", "r") as fh: + long_description = fh.read() + +setup(name='ucloud', + version='0.1', + description='All ucloud server components.', + url='https://code.ungleich.ch/ucloud/ucloud', + long_description=long_description, + long_description_content_type='text/markdown', + classifiers=[ + 'Development Status :: 3 - Alpha', + 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', + 'Programming Language :: Python :: 3' + ], + author='ungleich', + author_email='technik@ungleich.ch', + packages=find_packages(), + install_requires=[ + 'requests', + 'python-decouple', + 'flask', + 'flask-restful', + 'bitmath', + 'pyotp', + 'sshtunnel', + 'sphinx', + 'pynetbox', + 'sphinx-rtd-theme', + 'etcd3_wrapper @ https://code.ungleich.ch/ungleich-public/etcd3_wrapper/repository/master/archive.tar.gz#egg=etcd3_wrapper', + 'etcd3 @ https://github.com/kragniz/python-etcd3/tarball/master#egg=etcd3', + ], + scripts=['bin/ucloud'], + zip_safe=False) \ No newline at end of file diff --git a/__init__.py b/ucloud/__init__.py similarity index 100% rename from __init__.py rename to ucloud/__init__.py diff --git a/api/README.md b/ucloud/api/README.md similarity index 100% rename from api/README.md rename to ucloud/api/README.md diff --git a/api/__init__.py b/ucloud/api/__init__.py similarity index 100% rename from api/__init__.py rename to ucloud/api/__init__.py diff --git a/api/common_fields.py b/ucloud/api/common_fields.py similarity index 96% rename from api/common_fields.py rename to ucloud/api/common_fields.py index 6a68763..e9903ac 100755 --- a/api/common_fields.py +++ b/ucloud/api/common_fields.py @@ -1,6 +1,6 @@ import os -from config import etcd_client, env_vars +from ucloud.config import etcd_client, env_vars class Optional: diff --git a/api/create_image_store.py b/ucloud/api/create_image_store.py similarity index 87% rename from api/create_image_store.py rename to ucloud/api/create_image_store.py index cddbacb..99e33c2 100755 --- a/api/create_image_store.py +++ b/ucloud/api/create_image_store.py @@ -2,7 +2,7 @@ import json import os from uuid import uuid4 -from config import etcd_client, env_vars +from ucloud.config import etcd_client, env_vars data = { "is_public": True, diff --git a/api/helper.py b/ucloud/api/helper.py similarity index 94% rename from api/helper.py rename to ucloud/api/helper.py index ed3ea28..3271461 100755 --- a/api/helper.py +++ b/ucloud/api/helper.py @@ -6,7 +6,7 @@ import subprocess as sp import requests from pyotp import TOTP -from config import vm_pool, env_vars +from ucloud.config import vm_pool, env_vars def check_otp(name, realm, token): @@ -83,8 +83,8 @@ def resolve_image_name(name, etcd_client): # Try to find image with name == image_name and store_name == store_name try: - image = next(filter(lambda im: im.value['name'] == image_name \ - and im.value['store_name'] == store_name, images)) + image = next(filter(lambda im: im.value['name'] == image_name + and im.value['store_name'] == store_name, images)) except StopIteration: raise KeyError("No image with name {} found.".format(name)) else: @@ -155,10 +155,10 @@ def mac2ipv6(mac, prefix): parts[0] = "%x" % (int(parts[0], 16) ^ 2) # format output - ipv6Parts = [str(0)] * 4 + ipv6_parts = [str(0)] * 4 for i in range(0, len(parts), 2): - ipv6Parts.append("".join(parts[i:i + 2])) + ipv6_parts.append("".join(parts[i:i + 2])) - lower_part = ipaddress.IPv6Address(":".join(ipv6Parts)) + lower_part = ipaddress.IPv6Address(":".join(ipv6_parts)) prefix = ipaddress.IPv6Address(prefix) return str(prefix + int(lower_part)) diff --git a/api/main.py b/ucloud/api/main.py similarity index 97% rename from api/main.py rename to ucloud/api/main.py index 224ab2e..d325ecb 100644 --- a/api/main.py +++ b/ucloud/api/main.py @@ -7,13 +7,13 @@ from os.path import join as join_path from flask import Flask, request from flask_restful import Resource, Api -from common import counters -from common.vm import VMStatus -from common.request import RequestEntry, RequestType -from config import (etcd_client, request_pool, vm_pool, host_pool, env_vars, image_storage_handler) +from ucloud.common import counters +from ucloud.common.vm import VMStatus +from ucloud.common.request import RequestEntry, RequestType +from ucloud.config import (etcd_client, request_pool, vm_pool, host_pool, env_vars, image_storage_handler) from . import schemas from .helper import generate_mac, mac2ipv6 -from api import logger +from ucloud.api import logger app = Flask(__name__) api = Api(app) @@ -35,9 +35,9 @@ class CreateVM(Resource): "os-ssd": validator.specs["os-ssd"], "hdd": validator.specs["hdd"], } - macs = [generate_mac() for i in range(len(data["network"]))] + macs = [generate_mac() for _ in range(len(data["network"]))] tap_ids = [counters.increment_etcd_counter(etcd_client, "/v1/counter/tap") - for i in range(len(data["network"]))] + for _ in range(len(data["network"]))] vm_entry = { "name": data["vm_name"], "owner": data["name"], diff --git a/api/schemas.py b/ucloud/api/schemas.py similarity index 99% rename from api/schemas.py rename to ucloud/api/schemas.py index e50d9f0..c4f60ca 100755 --- a/api/schemas.py +++ b/ucloud/api/schemas.py @@ -19,9 +19,9 @@ import os import bitmath -from common.host import HostStatus -from common.vm import VMStatus -from config import etcd_client, env_vars, vm_pool, host_pool +from ucloud.common.host import HostStatus +from ucloud.common.vm import VMStatus +from ucloud.config import etcd_client, env_vars, vm_pool, host_pool from . import helper from .common_fields import Field, VmUUIDField from .helper import check_otp, resolve_vm_name diff --git a/filescanner/__init__.py b/ucloud/common/__init__.py similarity index 100% rename from filescanner/__init__.py rename to ucloud/common/__init__.py diff --git a/common/classes.py b/ucloud/common/classes.py similarity index 100% rename from common/classes.py rename to ucloud/common/classes.py diff --git a/common/counters.py b/ucloud/common/counters.py similarity index 100% rename from common/counters.py rename to ucloud/common/counters.py diff --git a/common/helpers.py b/ucloud/common/helpers.py similarity index 100% rename from common/helpers.py rename to ucloud/common/helpers.py diff --git a/common/host.py b/ucloud/common/host.py similarity index 100% rename from common/host.py rename to ucloud/common/host.py diff --git a/common/request.py b/ucloud/common/request.py similarity index 100% rename from common/request.py rename to ucloud/common/request.py diff --git a/common/storage_handlers.py b/ucloud/common/storage_handlers.py similarity index 99% rename from common/storage_handlers.py rename to ucloud/common/storage_handlers.py index c74bca8..8b1097a 100644 --- a/common/storage_handlers.py +++ b/ucloud/common/storage_handlers.py @@ -4,7 +4,7 @@ import os import stat from abc import ABC -from host import logger +from . import logger from os.path import join as join_path diff --git a/common/vm.py b/ucloud/common/vm.py similarity index 100% rename from common/vm.py rename to ucloud/common/vm.py diff --git a/config.py b/ucloud/config.py similarity index 83% rename from config.py rename to ucloud/config.py index c58cf33..d5e5b77 100644 --- a/config.py +++ b/ucloud/config.py @@ -1,9 +1,9 @@ from etcd3_wrapper import Etcd3Wrapper -from common.host import HostPool -from common.request import RequestPool -from common.vm import VmPool -from common.storage_handlers import FileSystemBasedImageStorageHandler, CEPHBasedImageStorageHandler +from ucloud.common.host import HostPool +from ucloud.common.request import RequestPool +from ucloud.common.vm import VmPool +from ucloud.common.storage_handlers import FileSystemBasedImageStorageHandler, CEPHBasedImageStorageHandler from decouple import Config, RepositoryEnv diff --git a/docs/Makefile b/ucloud/docs/Makefile similarity index 100% rename from docs/Makefile rename to ucloud/docs/Makefile diff --git a/common/__init__.py b/ucloud/docs/__init__.py similarity index 100% rename from common/__init__.py rename to ucloud/docs/__init__.py diff --git a/docs/__init__.py b/ucloud/docs/source/__init__.py similarity index 100% rename from docs/__init__.py rename to ucloud/docs/source/__init__.py diff --git a/docs/source/conf.py b/ucloud/docs/source/conf.py similarity index 100% rename from docs/source/conf.py rename to ucloud/docs/source/conf.py diff --git a/docs/source/diagram-code/ucloud b/ucloud/docs/source/diagram-code/ucloud similarity index 100% rename from docs/source/diagram-code/ucloud rename to ucloud/docs/source/diagram-code/ucloud diff --git a/docs/source/images/ucloud.svg b/ucloud/docs/source/images/ucloud.svg similarity index 100% rename from docs/source/images/ucloud.svg rename to ucloud/docs/source/images/ucloud.svg diff --git a/docs/source/index.rst b/ucloud/docs/source/index.rst similarity index 100% rename from docs/source/index.rst rename to ucloud/docs/source/index.rst diff --git a/docs/source/introduction/installation.rst b/ucloud/docs/source/introduction/installation.rst similarity index 55% rename from docs/source/introduction/installation.rst rename to ucloud/docs/source/introduction/installation.rst index 450a9e7..87e71ff 100644 --- a/docs/source/introduction/installation.rst +++ b/ucloud/docs/source/introduction/installation.rst @@ -1,9 +1,16 @@ Installation ============ +This guides includes two type of installation + +* File System as Image Storage + Level 2 Network without IPAM and Routing +* CEPH as Image Storage + Level 2 Network with automatic IPAM and Routing + (using Router Advertisement + Netbox) + +The guide will explicitly mention a section/subsection if it is exclusive to any +one of the above mentioned scenario. + .. note:: - The below installation instructions are for single node and without ceph ucloud installation. - The instructions assumes the following things * User is **root**. @@ -13,14 +20,18 @@ Alpine ------ .. note:: - Python Wheel (Binary) Packages does not support Alpine Linux as it is using musl libc instead of glibc. - Therefore, expect longer installation times than other linux distributions. + Python Wheel (Binary) Packages does not support Alpine Linux as it is + using musl libc instead of glibc. Therefore, expect longer installation + times than other linux distributions. Enable Edge Repos, Update and Upgrade ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. warning:: - The below commands would overwrite your repositories sources and upgrade all packages and their dependencies to match those available in edge repos. **So, be warned** + The below commands would overwrite your repositories sources and + upgrade all packages and their dependencies to match those available + in edge repos. **So, be warned** + .. code-block:: sh :linenos: @@ -40,17 +51,31 @@ Install Dependencies ~~~~~~~~~~~~~~~~~~~~ .. note:: The installation and configuration of a production grade etcd cluster - is out of scope of this manual. So, we will install etcd with default configuration. + is out of scope of this manual. So, we will install etcd with default + configuration. .. code-block:: sh :linenos: apk add git python3 alpine-sdk python3-dev etcd etcd-ctl openntpd \ - libffi-dev openssl-dev make py3-protobuf py3-tempita chrony \ - qemu qemu-system-x86_64 qemu-img - + libffi-dev openssl-dev make py3-protobuf py3-tempita chrony + pip3 install pipenv + +**Install QEMU (For Filesystem based Installation)** + +.. code-block:: sh + + apk add qemu qemu-system-x86_64 qemu-img + +**Install QEMU/CEPH/radvd (For CEPH based Installation)** + +.. code-block:: sh + + $(git clone https://code.ungleich.ch/ahmedbilal/qemu-with-rbd-alpine.git && cd qemu-with-rbd-alpine && apk add apks/*.apk --allow-untrusted) + apk add ceph radvd + Syncronize Date/Time ~~~~~~~~~~~~~~~~~~~~ @@ -64,10 +89,15 @@ Syncronize Date/Time Start etcd and enable it ~~~~~~~~~~~~~~~~~~~~~~~~ +.. note:: + The following :command:`curl` statement shouldn't be run once + etcd is fixed in alpine repos. + .. code-block:: sh :linenos: - start-stop-daemon -b etcd + curl https://raw.githubusercontent.com/etcd-io/etcd/release-3.4/etcd.conf.yml.sample -o /etc/etcd/conf.yml + service etcd start rc-update add etcd @@ -85,7 +115,8 @@ Install uotp pipenv install pipenv run python app.py -Run :code:`ETCDCTL_API=3 etcdctl get /uotp/admin --print-value-only` to get admin seed. A sample output +Run :code:`$(cd scripts && pipenv run python get-admin.py)` to get +admin seed. A sample output .. code-block:: json @@ -94,17 +125,21 @@ Run :code:`ETCDCTL_API=3 etcdctl get /uotp/admin --print-value-only` to get admi "realm": ["ungleich-admin"] } -Now, run :code:`pipenv run python scripts/create-auth.py FYTVQ72A2CJJ4TB4` (Replace **FYTVQ72A2CJJ4TB4** with your admin seed obtained in previous step). +Now, run :code:`pipenv run python scripts/create-auth.py FYTVQ72A2CJJ4TB4` +(Replace **FYTVQ72A2CJJ4TB4** with your admin seed obtained in previous step). A sample output is as below. It shows seed of auth. .. code-block:: json { - "message": "Account Created\nname: auth, realm: ['ungleich-auth'], seed: XZLTUMX26TRAZOXC" + "message": "Account Created", + "name": "auth", + "realm": ["ungleich-auth"], + "seed": "XZLTUMX26TRAZOXC" } .. note:: - Please note both **admin** and **auth** seeds as we would need them in setting up ucloud + Please note both **admin** and **auth** seeds as we would need them in setting up ucloud. Install and configure ucloud @@ -119,14 +154,16 @@ Install and configure ucloud pipenv --three --site-packages pipenv install +**Filesystem based Installation** You just need to update **AUTH_SEED** in the below code to match your auth's seed. - .. code-block:: sh :linenos: - cat > .env << EOF + mkdir /etc/ucloud + + cat > /etc/ucloud/ucloud.conf << EOF AUTH_NAME=auth AUTH_SEED=XZLTUMX26TRAZOXC AUTH_REALM=ungleich-auth @@ -160,6 +197,60 @@ You just need to update **AUTH_SEED** in the below code to match your auth's see EOF + +**CEPH based Installation** +You need to update the following + +* **AUTH_SEED** +* **NETBOX_URL** +* **NETBOX_TOKEN** +* **PREFIX** +* **PREFIX_LENGTH** + + +.. code-block:: sh + :linenos: + + mkdir /etc/ucloud + + cat > /etc/ucloud/ucloud.conf << EOF + AUTH_NAME=auth + AUTH_SEED=XZLTUMX26TRAZOXC + AUTH_REALM=ungleich-auth + + REALM_ALLOWED = ["ungleich-admin", "ungleich-user"] + + OTP_SERVER="http://127.0.0.1:8000/" + + ETCD_URL=localhost + + STORAGE_BACKEND=ceph + + BASE_DIR=/var/www + IMAGE_DIR=/var/image + VM_DIR=/var/vm + + VM_PREFIX=/v1/vm/ + HOST_PREFIX=/v1/host/ + REQUEST_PREFIX=/v1/request/ + FILE_PREFIX=/v1/file/ + IMAGE_PREFIX=/v1/image/ + IMAGE_STORE_PREFIX=/v1/image_store/ + USER_PREFIX=/v1/user/ + NETWORK_PREFIX=/v1/network/ + + ssh_username=meow + ssh_pkey="~/.ssh/id_rsa" + + VXLAN_PHY_DEV="eth0" + + NETBOX_URL="" + NETBOX_TOKEN="netbox-token" + PREFIX="your-prefix" + PREFIX_LENGTH="64" + EOF + + Install and configure ucloud-cli ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -171,11 +262,16 @@ Install and configure ucloud-cli pipenv --three --site-packages pipenv install - cat > .env << EOF + cat > ~/.ucloud.conf << EOF UCLOUD_API_SERVER=http://localhost:5000 EOF mkdir /var/www/ + +**Only for Filesystem Based Installation** + +.. code-block:: sh + mkdir /var/image/ mkdir /var/vm/ @@ -183,8 +279,8 @@ Install and configure ucloud-cli Environment Variables and aliases ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -To ease usage of ucloud and its various componenets put the following in your shell -profile e.g *~/.profile* +To ease usage of ucloud and its various components put the following in +your shell profile e.g *~/.profile* .. code-block:: sh @@ -193,7 +289,7 @@ profile e.g *~/.profile* export OTP_SEED=FYTVQ72A2CJJ4TB4 alias ucloud='cd /root/ucloud/ && pipenv run python ucloud.py' - alias ucloud-cli='cd /root/ucloud-cli/ && pipenv run python ucloud.py' + alias ucloud-cli='cd /root/ucloud-cli/ && pipenv run python ucloud-cli.py' alias uotp='cd /root/uotp/ && pipenv run python app.py' and run :code:`source ~/.profile` diff --git a/docs/source/introduction/introduction.rst b/ucloud/docs/source/introduction/introduction.rst similarity index 86% rename from docs/source/introduction/introduction.rst rename to ucloud/docs/source/introduction/introduction.rst index f45b3c1..8f47acc 100644 --- a/docs/source/introduction/introduction.rst +++ b/ucloud/docs/source/introduction/introduction.rst @@ -17,8 +17,11 @@ Tech Stack * JSON for specifications. * QEMU (+ kvm acceleration) as hypervisor. * etcd for key/value storage (specifically all metadata e.g Virtual Machine Specifications, Networks Specifications, Images Specifications etc.). -* Ceph for image storage. +* CEPH for image storage. * uotp for user authentication. +* netbox for IPAM. +* radvd for Router Advertisement. + Components ---------- diff --git a/docs/source/misc/todo.rst b/ucloud/docs/source/misc/todo.rst similarity index 100% rename from docs/source/misc/todo.rst rename to ucloud/docs/source/misc/todo.rst diff --git a/docs/source/theory/summary.rst b/ucloud/docs/source/theory/summary.rst similarity index 98% rename from docs/source/theory/summary.rst rename to ucloud/docs/source/theory/summary.rst index 62f6200..864a797 100644 --- a/docs/source/theory/summary.rst +++ b/ucloud/docs/source/theory/summary.rst @@ -62,8 +62,8 @@ later for creating OS Images. format which would then be imported into image store. * In case of **File System**, the converted image would be copied to - :file:`/var/image/` or the path referred by :envvar:`IMAGE_PATH` environement variable - mentioned in :file:`/etc/ucloud/ucloud.conf`. + :file:`/var/image/` or the path referred by :envvar:`IMAGE_PATH` + environement variable mentioned in :file:`/etc/ucloud/ucloud.conf`. * In case of **CEPH**, the converted image would be imported into specific pool (it depends on the image store in which the image diff --git a/docs/source/troubleshooting/installation-troubleshooting.rst b/ucloud/docs/source/troubleshooting/installation-troubleshooting.rst similarity index 100% rename from docs/source/troubleshooting/installation-troubleshooting.rst rename to ucloud/docs/source/troubleshooting/installation-troubleshooting.rst diff --git a/docs/source/usage/how-to-create-an-os-image-for-ucloud.rst b/ucloud/docs/source/usage/how-to-create-an-os-image-for-ucloud.rst similarity index 100% rename from docs/source/usage/how-to-create-an-os-image-for-ucloud.rst rename to ucloud/docs/source/usage/how-to-create-an-os-image-for-ucloud.rst diff --git a/docs/source/usage/usage-for-admins.rst b/ucloud/docs/source/usage/usage-for-admins.rst similarity index 96% rename from docs/source/usage/usage-for-admins.rst rename to ucloud/docs/source/usage/usage-for-admins.rst index 3c20fb4..44298cc 100644 --- a/docs/source/usage/usage-for-admins.rst +++ b/ucloud/docs/source/usage/usage-for-admins.rst @@ -76,12 +76,12 @@ contexualize VMs. Upload Sample OS Image ~~~~~~~~~~~~~~~~~~~~~~ -Execute the following to upload the sample OS image file. +Execute the following to get the sample OS image file. .. code-block:: sh mkdir /var/www/admin - (cd /var/www/admin && wget http://[2a0a:e5c0:2:12:0:f0ff:fea9:c3d9]/alpine-untouched.qcow2) + (cd /var/www/admin && wget https://cloud.ungleich.ch/s/qTb5dFYW5ii8KsD/download) Run File Scanner and Image Scanner ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/usage/usage-for-users.rst b/ucloud/docs/source/usage/usage-for-users.rst similarity index 91% rename from docs/source/usage/usage-for-users.rst rename to ucloud/docs/source/usage/usage-for-users.rst index 315fa80..1c59408 100644 --- a/docs/source/usage/usage-for-users.rst +++ b/ucloud/docs/source/usage/usage-for-users.rst @@ -4,7 +4,8 @@ Usage Guide For End Users Create VM --------- -The following command would create a Virtual Machine (name: meow) with following specs +The following command would create a Virtual Machine (name: meow) +with following specs * CPU: 1 * RAM: 1GB @@ -55,8 +56,9 @@ Check VM Status Connect to VM using VNC ----------------------- -We would need **socat** utility and a remote desktop client e.g Remmina, KRDC etc. -We can get the vnc socket path by getting its status, see :ref:`how-to-check-vm-status`. +We would need **socat** utility and a remote desktop client +e.g Remmina, KRDC etc. We can get the vnc socket path by getting +its status, see :ref:`how-to-check-vm-status`. .. code-block:: sh diff --git a/host/__init__.py b/ucloud/filescanner/__init__.py similarity index 100% rename from host/__init__.py rename to ucloud/filescanner/__init__.py diff --git a/filescanner/main.py b/ucloud/filescanner/main.py similarity index 97% rename from filescanner/main.py rename to ucloud/filescanner/main.py index 385d31d..b9fefcc 100755 --- a/filescanner/main.py +++ b/ucloud/filescanner/main.py @@ -5,8 +5,8 @@ import subprocess as sp import time from uuid import uuid4 -from filescanner import logger -from config import env_vars, etcd_client +from ucloud.filescanner import logger +from ucloud.config import env_vars, etcd_client def getxattr(file, attr): diff --git a/hack/README.org b/ucloud/hack/README.org similarity index 100% rename from hack/README.org rename to ucloud/hack/README.org diff --git a/hack/conf.d/ucloud-host b/ucloud/hack/conf.d/ucloud-host similarity index 100% rename from hack/conf.d/ucloud-host rename to ucloud/hack/conf.d/ucloud-host diff --git a/hack/nftables.conf b/ucloud/hack/nftables.conf similarity index 100% rename from hack/nftables.conf rename to ucloud/hack/nftables.conf diff --git a/hack/rc-scripts/ucloud-api b/ucloud/hack/rc-scripts/ucloud-api similarity index 100% rename from hack/rc-scripts/ucloud-api rename to ucloud/hack/rc-scripts/ucloud-api diff --git a/hack/rc-scripts/ucloud-host b/ucloud/hack/rc-scripts/ucloud-host similarity index 100% rename from hack/rc-scripts/ucloud-host rename to ucloud/hack/rc-scripts/ucloud-host diff --git a/ucloud/hack/rc-scripts/ucloud-metadata b/ucloud/hack/rc-scripts/ucloud-metadata new file mode 100644 index 0000000..d41807f --- /dev/null +++ b/ucloud/hack/rc-scripts/ucloud-metadata @@ -0,0 +1,8 @@ +#!/sbin/openrc-run + +name="$RC_SVCNAME" +pidfile="/var/run/${name}.pid" +command="$(which pipenv)" +command_args="run python ucloud.py metadata" +command_background="true" +directory="/root/ucloud" \ No newline at end of file diff --git a/hack/rc-scripts/ucloud-scheduler b/ucloud/hack/rc-scripts/ucloud-scheduler similarity index 100% rename from hack/rc-scripts/ucloud-scheduler rename to ucloud/hack/rc-scripts/ucloud-scheduler diff --git a/imagescanner/__init__.py b/ucloud/host/__init__.py similarity index 100% rename from imagescanner/__init__.py rename to ucloud/host/__init__.py diff --git a/host/helper.py b/ucloud/host/helper.py similarity index 100% rename from host/helper.py rename to ucloud/host/helper.py diff --git a/host/main.py b/ucloud/host/main.py similarity index 98% rename from host/main.py rename to ucloud/host/main.py index 9b12c30..1a3e449 100755 --- a/host/main.py +++ b/ucloud/host/main.py @@ -4,8 +4,8 @@ import time from etcd3_wrapper import Etcd3Wrapper -from common.request import RequestEntry, RequestType -from config import (vm_pool, request_pool, +from ucloud.common.request import RequestEntry, RequestType +from ucloud.config import (vm_pool, request_pool, etcd_client, running_vms, etcd_wrapper_args, etcd_wrapper_kwargs, HostPool, env_vars) diff --git a/host/qmp/__init__.py b/ucloud/host/qmp/__init__.py similarity index 100% rename from host/qmp/__init__.py rename to ucloud/host/qmp/__init__.py diff --git a/host/qmp/qmp.py b/ucloud/host/qmp/qmp.py similarity index 100% rename from host/qmp/qmp.py rename to ucloud/host/qmp/qmp.py diff --git a/host/virtualmachine.py b/ucloud/host/virtualmachine.py similarity index 97% rename from host/virtualmachine.py rename to ucloud/host/virtualmachine.py index 52bf7dc..ce23daa 100755 --- a/host/virtualmachine.py +++ b/ucloud/host/virtualmachine.py @@ -18,10 +18,10 @@ from os.path import join as join_path import bitmath import sshtunnel -from common.helpers import get_ipv6_address -from common.request import RequestEntry, RequestType -from common.vm import VMEntry, VMStatus -from config import etcd_client, request_pool, running_vms, vm_pool, env_vars, image_storage_handler +from ucloud.common.helpers import get_ipv6_address +from ucloud.common.request import RequestEntry, RequestType +from ucloud.common.vm import VMEntry, VMStatus +from ucloud.config import etcd_client, request_pool, running_vms, vm_pool, env_vars, image_storage_handler from . import qmp from host import logger diff --git a/ucloud/imagescanner/__init__.py b/ucloud/imagescanner/__init__.py new file mode 100644 index 0000000..eea436a --- /dev/null +++ b/ucloud/imagescanner/__init__.py @@ -0,0 +1,3 @@ +import logging + +logger = logging.getLogger(__name__) diff --git a/imagescanner/main.py b/ucloud/imagescanner/main.py similarity index 96% rename from imagescanner/main.py rename to ucloud/imagescanner/main.py index 4b41642..20ce9d5 100755 --- a/imagescanner/main.py +++ b/ucloud/imagescanner/main.py @@ -3,8 +3,8 @@ import os import subprocess from os.path import join as join_path -from config import etcd_client, env_vars, image_storage_handler -from imagescanner import logger +from ucloud.config import etcd_client, env_vars, image_storage_handler +from ucloud.imagescanner import logger def qemu_img_type(path): diff --git a/docs/source/__init__.py b/ucloud/metadata/__init__.py similarity index 100% rename from docs/source/__init__.py rename to ucloud/metadata/__init__.py diff --git a/metadata/main.py b/ucloud/metadata/main.py similarity index 98% rename from metadata/main.py rename to ucloud/metadata/main.py index 7176d41..e7cb33b 100644 --- a/metadata/main.py +++ b/ucloud/metadata/main.py @@ -3,7 +3,7 @@ import os from flask import Flask, request from flask_restful import Resource, Api -from config import etcd_client, env_vars, vm_pool +from ucloud.config import etcd_client, env_vars, vm_pool app = Flask(__name__) api = Api(app) diff --git a/network/README b/ucloud/network/README similarity index 100% rename from network/README rename to ucloud/network/README diff --git a/metadata/__init__.py b/ucloud/network/__init__.py similarity index 100% rename from metadata/__init__.py rename to ucloud/network/__init__.py diff --git a/network/create-bridge.sh b/ucloud/network/create-bridge.sh similarity index 100% rename from network/create-bridge.sh rename to ucloud/network/create-bridge.sh diff --git a/network/create-tap.sh b/ucloud/network/create-tap.sh similarity index 100% rename from network/create-tap.sh rename to ucloud/network/create-tap.sh diff --git a/network/create-vxlan.sh b/ucloud/network/create-vxlan.sh similarity index 100% rename from network/create-vxlan.sh rename to ucloud/network/create-vxlan.sh diff --git a/network/radvd-template.conf b/ucloud/network/radvd-template.conf similarity index 100% rename from network/radvd-template.conf rename to ucloud/network/radvd-template.conf diff --git a/sanity_checks.py b/ucloud/sanity_checks.py similarity index 95% rename from sanity_checks.py rename to ucloud/sanity_checks.py index 2c645a5..143f767 100644 --- a/sanity_checks.py +++ b/ucloud/sanity_checks.py @@ -2,7 +2,7 @@ import sys import subprocess as sp from os.path import isdir -from config import env_vars +from ucloud.config import env_vars def check(): diff --git a/scheduler/__init__.py b/ucloud/scheduler/__init__.py similarity index 100% rename from scheduler/__init__.py rename to ucloud/scheduler/__init__.py diff --git a/scheduler/helper.py b/ucloud/scheduler/helper.py similarity index 94% rename from scheduler/helper.py rename to ucloud/scheduler/helper.py index 79bfd70..ba577d6 100755 --- a/scheduler/helper.py +++ b/ucloud/scheduler/helper.py @@ -3,10 +3,10 @@ from functools import reduce import bitmath -from common.host import HostStatus -from common.request import RequestEntry, RequestType -from common.vm import VMStatus -from config import vm_pool, host_pool, request_pool, env_vars +from ucloud.common.host import HostStatus +from ucloud.common.request import RequestEntry, RequestType +from ucloud.common.vm import VMStatus +from ucloud.config import vm_pool, host_pool, request_pool, env_vars def accumulated_specs(vms_specs): diff --git a/scheduler/main.py b/ucloud/scheduler/main.py similarity index 95% rename from scheduler/main.py rename to ucloud/scheduler/main.py index 4511dcc..54d81d6 100755 --- a/scheduler/main.py +++ b/ucloud/scheduler/main.py @@ -4,12 +4,12 @@ # 2. Introduce a status endpoint of the scheduler - # maybe expose a prometheus compatible output -from common.request import RequestEntry, RequestType -from config import etcd_client -from config import host_pool, request_pool, vm_pool, env_vars +from ucloud.common.request import RequestEntry, RequestType +from ucloud.config import etcd_client +from ucloud.config import host_pool, request_pool, vm_pool, env_vars from .helper import (get_suitable_host, dead_host_mitigation, dead_host_detection, assign_host, NoSuitableHostFound) -from scheduler import logger +from ucloud.scheduler import logger def main(): diff --git a/network/__init__.py b/ucloud/scheduler/tests/__init__.py similarity index 100% rename from network/__init__.py rename to ucloud/scheduler/tests/__init__.py diff --git a/scheduler/tests/test_basics.py b/ucloud/scheduler/tests/test_basics.py similarity index 99% rename from scheduler/tests/test_basics.py rename to ucloud/scheduler/tests/test_basics.py index ef82fc0..92b3a83 100755 --- a/scheduler/tests/test_basics.py +++ b/ucloud/scheduler/tests/test_basics.py @@ -15,7 +15,7 @@ from main import ( main, ) -from config import etcd_client +from ucloud.config import etcd_client class TestFunctions(unittest.TestCase): diff --git a/scheduler/tests/test_dead_host_mechanism.py b/ucloud/scheduler/tests/test_dead_host_mechanism.py similarity index 100% rename from scheduler/tests/test_dead_host_mechanism.py rename to ucloud/scheduler/tests/test_dead_host_mechanism.py From abc2c6fe5176b2d0b5cc730038ae139f9ea33c76 Mon Sep 17 00:00:00 2001 From: meow Date: Tue, 3 Dec 2019 16:49:10 +0500 Subject: [PATCH 027/284] LICENSE added + fixed some imports --- LICENSE | 674 +++++++++++++++++++++++++++++++ ucloud/api/create_image_store.py | 1 + ucloud/api/helper.py | 1 + ucloud/api/main.py | 2 +- ucloud/filescanner/main.py | 2 +- ucloud/host/main.py | 2 +- ucloud/host/virtualmachine.py | 6 +- ucloud/scheduler/main.py | 2 +- 8 files changed, 684 insertions(+), 6 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/ucloud/api/create_image_store.py b/ucloud/api/create_image_store.py index 99e33c2..17fa63c 100755 --- a/ucloud/api/create_image_store.py +++ b/ucloud/api/create_image_store.py @@ -1,5 +1,6 @@ import json import os + from uuid import uuid4 from ucloud.config import etcd_client, env_vars diff --git a/ucloud/api/helper.py b/ucloud/api/helper.py index 3271461..63d2f90 100755 --- a/ucloud/api/helper.py +++ b/ucloud/api/helper.py @@ -4,6 +4,7 @@ import random import subprocess as sp import requests + from pyotp import TOTP from ucloud.config import vm_pool, env_vars diff --git a/ucloud/api/main.py b/ucloud/api/main.py index d325ecb..1475fb0 100644 --- a/ucloud/api/main.py +++ b/ucloud/api/main.py @@ -13,7 +13,7 @@ from ucloud.common.request import RequestEntry, RequestType from ucloud.config import (etcd_client, request_pool, vm_pool, host_pool, env_vars, image_storage_handler) from . import schemas from .helper import generate_mac, mac2ipv6 -from ucloud.api import logger +from . import logger app = Flask(__name__) api = Api(app) diff --git a/ucloud/filescanner/main.py b/ucloud/filescanner/main.py index b9fefcc..b70cb5b 100755 --- a/ucloud/filescanner/main.py +++ b/ucloud/filescanner/main.py @@ -5,7 +5,7 @@ import subprocess as sp import time from uuid import uuid4 -from ucloud.filescanner import logger +from . import logger from ucloud.config import env_vars, etcd_client diff --git a/ucloud/host/main.py b/ucloud/host/main.py index 1a3e449..ccf0a8d 100755 --- a/ucloud/host/main.py +++ b/ucloud/host/main.py @@ -12,7 +12,7 @@ from ucloud.config import (vm_pool, request_pool, from .helper import find_free_port from . import virtualmachine -from host import logger +from ucloud.host import logger def update_heartbeat(hostname): diff --git a/ucloud/host/virtualmachine.py b/ucloud/host/virtualmachine.py index ce23daa..bb26d25 100755 --- a/ucloud/host/virtualmachine.py +++ b/ucloud/host/virtualmachine.py @@ -21,9 +21,11 @@ import sshtunnel from ucloud.common.helpers import get_ipv6_address from ucloud.common.request import RequestEntry, RequestType from ucloud.common.vm import VMEntry, VMStatus -from ucloud.config import etcd_client, request_pool, running_vms, vm_pool, env_vars, image_storage_handler +from ucloud.config import (etcd_client, request_pool, + running_vms, vm_pool, env_vars, + image_storage_handler) from . import qmp -from host import logger +from ucloud.host import logger class VM: diff --git a/ucloud/scheduler/main.py b/ucloud/scheduler/main.py index 54d81d6..e2c975a 100755 --- a/ucloud/scheduler/main.py +++ b/ucloud/scheduler/main.py @@ -9,7 +9,7 @@ from ucloud.config import etcd_client from ucloud.config import host_pool, request_pool, vm_pool, env_vars from .helper import (get_suitable_host, dead_host_mitigation, dead_host_detection, assign_host, NoSuitableHostFound) -from ucloud.scheduler import logger +from . import logger def main(): From ad87982cf0a7bca5c1fa45d8146828d284a595a8 Mon Sep 17 00:00:00 2001 From: meow Date: Thu, 5 Dec 2019 18:30:41 +0500 Subject: [PATCH 028/284] ucloud now logs to /etc/ucloud/log.txt, delete network interfaces on stopping of VMs --- bin/ucloud | 2 +- ucloud/common/vm.py | 5 ++++ ucloud/host/virtualmachine.py | 45 +++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/bin/ucloud b/bin/ucloud index 0d4309a..7be6b24 100644 --- a/bin/ucloud +++ b/bin/ucloud @@ -19,7 +19,7 @@ if __name__ == "__main__": logging.basicConfig( level=logging.DEBUG, - filename=join_path("logs.txt"), + filename=join_path("/", "etc", "ucloud", "log.txt"), filemode="a", format="%(name)s %(asctime)s: %(levelname)s - %(message)s", datefmt="%d-%b-%y %H:%M:%S", diff --git a/ucloud/common/vm.py b/ucloud/common/vm.py index 1f5e43e..0fb5cea 100644 --- a/ucloud/common/vm.py +++ b/ucloud/common/vm.py @@ -65,6 +65,11 @@ class VmPool: _vms = self.vms return list(filter(lambda x: x.status == status, _vms)) + def by_owner(self, owner, _vms=None): + if _vms is None: + _vms = self.vms + return list(filter(lambda x: x.owner == owner, _vms)) + def except_status(self, status, _vms=None): if _vms is None: _vms = self.vms diff --git a/ucloud/host/virtualmachine.py b/ucloud/host/virtualmachine.py index bb26d25..7524083 100755 --- a/ucloud/host/virtualmachine.py +++ b/ucloud/host/virtualmachine.py @@ -38,6 +38,50 @@ class VM: return "VM({})".format(self.key) +def delete_network_interface(iface): + try: + sp.check_output(['ip', 'link', 'del', iface]) + except Exception: + pass + + +def resolve_network(network_name, network_owner): + network = etcd_client.get(join_path(env_vars.get("NETWORK_PREFIX"), + network_owner, + network_name), + value_in_json=True) + return network + + +def delete_vm_network(vm_entry): + try: + for network in vm_entry.network: + network_name = network[0] + tap_mac = network[1] + tap_id = network[2] + + delete_network_interface('tap{}'.format(tap_id)) + + owners_vms = vm_pool.by_owner(vm_entry.owner) + owners_running_vms = vm_pool.by_status(VMStatus.running, + _vms=owners_vms) + + networks = map(lambda n: n[0], + map(lambda vm: vm.network, owners_running_vms) + ) + networks_in_use_by_user_vms = [vm[0] for vm in networks] + if network_name not in networks_in_use_by_user_vms: + network_entry = resolve_network(network[0], vm_entry.owner) + if network_entry: + network_type = network_entry.value["type"] + network_id = network_entry.value["id"] + if network_type == "vxlan": + delete_network_interface('br{}'.format(network_id)) + delete_network_interface('vxlan{}'.format(network_id)) + except Exception: + logger.exception("Exception in network interface deletion") + + def create_dev(script, _id, dev, ip=None): command = [script, _id, dev] if ip: @@ -237,6 +281,7 @@ def stop(vm_entry): vm_entry.declare_stopped() vm_pool.put(vm_entry) running_vms.remove(vm) + delete_vm_network(vm_entry) def delete(vm_entry): From 9f03f58d62e260ec2526661cd4624f423d106222 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 7 Dec 2019 00:49:57 +0100 Subject: [PATCH 029/284] ++net notes Signed-off-by: Nico Schottelius --- ucloud/hack/README.org | 6 ++++++ ucloud/hack/nftables.conf | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/ucloud/hack/README.org b/ucloud/hack/README.org index a4668dd..7529263 100644 --- a/ucloud/hack/README.org +++ b/ucloud/hack/README.org @@ -5,3 +5,9 @@ This directory contains unfinishe hacks / inspirations *** might have scaling issues? ** firewall rules on each VM host - mac filtering: +* To add / block +** TODO arp poisoning +** TODO ndp "poisoning" +** TODO ipv4 dhcp server +*** drop dhcpv4 requests +*** drop dhcpv4 answers diff --git a/ucloud/hack/nftables.conf b/ucloud/hack/nftables.conf index 3758db0..7d1742e 100644 --- a/ucloud/hack/nftables.conf +++ b/ucloud/hack/nftables.conf @@ -69,9 +69,26 @@ table ip6 filter { chain vmXXXX { ether saddr != 00:0f:54:0c:11:04 drop; + ip6 saddr != 2001:db8:1:000f::540c:11ff:fe04 drop; + jump drop_from_vm_without_ipam + } + + chain net_2a0ae5c05something { + + + } + + chain drop_from_vm_without_ipam { + } chain vmYYYY { ether saddr != 00:0f:54:0c:11:05 drop; + jump drop_from_vm_with_ipam + } + + # Drop stuff from every VM + chain drop_from_vm_with_ipam { + icmpv6 type {nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, nd-redirect } drop } } \ No newline at end of file From 95361c17597f10784d3e8b561339fa9e52684ed7 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 7 Dec 2019 10:50:52 +0100 Subject: [PATCH 030/284] Ignore local etcd config directory --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8cd5f99..79b89a9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ __pycache__ ucloud/docs/build logs.txt -ucloud.egg-info \ No newline at end of file +ucloud.egg-info + +default.etcd From 4a6f119a930698ba305592f042bd371ba6c3fc06 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 7 Dec 2019 11:24:04 +0100 Subject: [PATCH 031/284] cleanup docs #1 --- ucloud/docs/source/index.rst | 10 ++++---- .../alpine.rst} | 0 ucloud/docs/source/installation/archlinux.rst | 25 +++++++++++++++++++ .../docs/source/introduction/howitworks.rst | 20 +++++++++++++++ .../{introduction.rst => whatis.rst} | 0 5 files changed, 50 insertions(+), 5 deletions(-) rename ucloud/docs/source/{introduction/installation.rst => installation/alpine.rst} (100%) create mode 100644 ucloud/docs/source/installation/archlinux.rst create mode 100644 ucloud/docs/source/introduction/howitworks.rst rename ucloud/docs/source/introduction/{introduction.rst => whatis.rst} (100%) diff --git a/ucloud/docs/source/index.rst b/ucloud/docs/source/index.rst index 6443af1..1f0530d 100644 --- a/ucloud/docs/source/index.rst +++ b/ucloud/docs/source/index.rst @@ -10,14 +10,14 @@ Welcome to ucloud's documentation! :maxdepth: 2 :caption: Contents: - introduction/introduction - introduction/installation + introduction/whatis + introduction/howitworks + installation/alpine + installation/archlinux usage/usage-for-admins usage/usage-for-users usage/how-to-create-an-os-image-for-ucloud - theory/summary - misc/todo - troubleshooting/installation-troubleshooting + Indices and tables ================== diff --git a/ucloud/docs/source/introduction/installation.rst b/ucloud/docs/source/installation/alpine.rst similarity index 100% rename from ucloud/docs/source/introduction/installation.rst rename to ucloud/docs/source/installation/alpine.rst diff --git a/ucloud/docs/source/installation/archlinux.rst b/ucloud/docs/source/installation/archlinux.rst new file mode 100644 index 0000000..b2a7f86 --- /dev/null +++ b/ucloud/docs/source/installation/archlinux.rst @@ -0,0 +1,25 @@ +Arch Linux +---------- + +Requirements from Arch Linux +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: sh + :linenos: + + pacman -Syu qemu + + +Requirements from AUR +~~~~~~~~~~~~~~~~~~~~~ + +Use your favorite AUR manager to install the following packages: + +* etcd + + +Other +~~~~~ +Continue like Alpine for + +* uotp diff --git a/ucloud/docs/source/introduction/howitworks.rst b/ucloud/docs/source/introduction/howitworks.rst new file mode 100644 index 0000000..9b66e94 --- /dev/null +++ b/ucloud/docs/source/introduction/howitworks.rst @@ -0,0 +1,20 @@ +How does ucloud work? +===================== + +ucloud is separeted into 3 systems: + +1. The client side for using ucloud +2. The server side +3. The supporting infrastructure (databases, virtualisation, etc.) + + +Depending on your use case, select one of the following guides to +continue: + +* I want to use +* I want to operate/run ucloud as a service + + +Architecture +------------ +Description of the ucloud architecture diff --git a/ucloud/docs/source/introduction/introduction.rst b/ucloud/docs/source/introduction/whatis.rst similarity index 100% rename from ucloud/docs/source/introduction/introduction.rst rename to ucloud/docs/source/introduction/whatis.rst From a8c20e5a30926f2f64f95b1109dc942a24c29954 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 7 Dec 2019 12:01:59 +0100 Subject: [PATCH 032/284] ++doc cleanup --- ucloud/docs/source/index.rst | 7 +- ucloud/docs/source/introduction.rst | 94 +++++++++++++++++++ .../docs/source/introduction/howitworks.rst | 4 + ucloud/docs/source/usage/usage-for-admins.rst | 11 ++- .../usage-for-users.rst => user-guide.rst} | 4 +- 5 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 ucloud/docs/source/introduction.rst rename ucloud/docs/source/{usage/usage-for-users.rst => user-guide.rst} (98%) diff --git a/ucloud/docs/source/index.rst b/ucloud/docs/source/index.rst index 1f0530d..74427b5 100644 --- a/ucloud/docs/source/index.rst +++ b/ucloud/docs/source/index.rst @@ -10,12 +10,13 @@ Welcome to ucloud's documentation! :maxdepth: 2 :caption: Contents: - introduction/whatis - introduction/howitworks + introduction + users-guide + installation/alpine installation/archlinux usage/usage-for-admins - usage/usage-for-users + usage/how-to-create-an-os-image-for-ucloud diff --git a/ucloud/docs/source/introduction.rst b/ucloud/docs/source/introduction.rst new file mode 100644 index 0000000..eba2020 --- /dev/null +++ b/ucloud/docs/source/introduction.rst @@ -0,0 +1,94 @@ +Introduction +============ + +ucloud is a modern, IPv6 first virtual machine management system. +It is an alternative to `OpenNebula `_, +`OpenStack `_ or +`Cloudstack `_. + +ucloud is the first cloud management system that puts IPv6 +first. ucloud also has an integral ordering process that we missed in +existing solutions. + +The ucloud documentation is separated into various sections for the +different use cases: + +* :ref:`The user guide ` describes how to use an existing + ucloud installation +* There are :ref:`setup instructions ` which describe on how to setup a new + ucloud instance +* :ref:`The admin guide ` describe on how to + administrate ucloud + + +Architecture +------------ + + + +suuuuuuubsub +^^^^^^^^^^^^ + +and following a + +para +"""" + +para2? + +para2! +"""""" +mo moo! + +Introduction + +What is ucloud? +~~~~~~~~~~~~~~~ + + + +Tech Stack +---------- +* Python 3 as main language. +* Flask for APIs. +* JSON for specifications. +* QEMU (+ kvm acceleration) as hypervisor. +* etcd for key/value storage (specifically all metadata e.g Virtual Machine Specifications, Networks Specifications, Images Specifications etc.). +* CEPH for image storage. +* uotp for user authentication. +* netbox for IPAM. +* radvd for Router Advertisement. + + +Components +---------- +* API +* Scheduler +* Host +* File Scanner +* Image Scanner +* Metadata Server +* VM Init Scripts (dubbed as ucloud-init)How does ucloud work? +===================== + +ucloud is separeted into 3 systems: + +1. The client side for using ucloud +2. The server side +3. The supporting infrastructure (databases, virtualisation, etc.) + + +Depending on your use case, select one of the following guides to +continue: + +* I want to use +* I want to operate/run ucloud as a service + + +Architecture +------------ +Description of the ucloud architecture + + +Authentication +~~~~~~~~~~~~~~ diff --git a/ucloud/docs/source/introduction/howitworks.rst b/ucloud/docs/source/introduction/howitworks.rst index 9b66e94..e3278f9 100644 --- a/ucloud/docs/source/introduction/howitworks.rst +++ b/ucloud/docs/source/introduction/howitworks.rst @@ -18,3 +18,7 @@ continue: Architecture ------------ Description of the ucloud architecture + + +Authentication +~~~~~~~~~~~~~~ diff --git a/ucloud/docs/source/usage/usage-for-admins.rst b/ucloud/docs/source/usage/usage-for-admins.rst index 44298cc..ec6597d 100644 --- a/ucloud/docs/source/usage/usage-for-admins.rst +++ b/ucloud/docs/source/usage/usage-for-admins.rst @@ -1,3 +1,6 @@ +.. _admin-guide: + + Usage Guide For Administrators ============================== @@ -11,11 +14,11 @@ Start API Host Creation ------------- -Currently, we don't have any host (that runs virtual machines). +Currently, we don't have any host (that runs virtual machines). So, we need to create it by executing the following command .. code-block:: sh - + ucloud-cli host create --hostname ungleich.ch --cpu 32 --ram '32GB' --os-ssd '32GB' You should see something like the following @@ -140,11 +143,11 @@ Now, ucloud have received our request to create an image from file. We have to r ucloud imagescanner -To make sure, that our image is create run :code:`ucloud-cli image list --public`. You would get +To make sure, that our image is create run :code:`ucloud-cli image list --public`. You would get output something like the following .. code-block:: json - + { "images": [ { diff --git a/ucloud/docs/source/usage/usage-for-users.rst b/ucloud/docs/source/user-guide.rst similarity index 98% rename from ucloud/docs/source/usage/usage-for-users.rst rename to ucloud/docs/source/user-guide.rst index 1c59408..f2ea9b2 100644 --- a/ucloud/docs/source/usage/usage-for-users.rst +++ b/ucloud/docs/source/user-guide.rst @@ -1,3 +1,5 @@ +.. _user-guide: + Usage Guide For End Users ========================= @@ -116,4 +118,4 @@ Migrate VM .. option:: --destination The name of destination host. You can find a list of host - using :ref:`get-list-of-hosts` \ No newline at end of file + using :ref:`get-list-of-hosts` From 1f0dc30730b051c40b706ed9cfccbaabfc3ddf17 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 7 Dec 2019 12:46:39 +0100 Subject: [PATCH 033/284] more doc cleanups --- .../usage-for-admins.rst => admin-guide} | 0 ucloud/docs/source/index.rst | 12 ++-- ucloud/docs/source/installation/archlinux.rst | 25 ------- ucloud/docs/source/introduction.rst | 70 +++++-------------- .../docs/source/introduction/howitworks.rst | 24 ------- ucloud/docs/source/introduction/whatis.rst | 34 --------- .../alpine.rst => setup-install.rst} | 57 +++++++++++---- ...roubleshooting.rst => troubleshooting.rst} | 0 ucloud/docs/source/user-guide.rst | 4 +- .../how-to-create-an-os-image-for-ucloud.rst | 0 10 files changed, 66 insertions(+), 160 deletions(-) rename ucloud/docs/source/{usage/usage-for-admins.rst => admin-guide} (100%) delete mode 100644 ucloud/docs/source/installation/archlinux.rst delete mode 100644 ucloud/docs/source/introduction/howitworks.rst delete mode 100644 ucloud/docs/source/introduction/whatis.rst rename ucloud/docs/source/{installation/alpine.rst => setup-install.rst} (88%) rename ucloud/docs/source/{troubleshooting/installation-troubleshooting.rst => troubleshooting.rst} (100%) rename ucloud/docs/source/{usage => user-guide}/how-to-create-an-os-image-for-ucloud.rst (100%) diff --git a/ucloud/docs/source/usage/usage-for-admins.rst b/ucloud/docs/source/admin-guide similarity index 100% rename from ucloud/docs/source/usage/usage-for-admins.rst rename to ucloud/docs/source/admin-guide diff --git a/ucloud/docs/source/index.rst b/ucloud/docs/source/index.rst index 74427b5..879ac32 100644 --- a/ucloud/docs/source/index.rst +++ b/ucloud/docs/source/index.rst @@ -11,13 +11,11 @@ Welcome to ucloud's documentation! :caption: Contents: introduction - users-guide - - installation/alpine - installation/archlinux - usage/usage-for-admins - - usage/how-to-create-an-os-image-for-ucloud + user-guide + setup-install + admin-guide + user-guide/how-to-create-an-os-image-for-ucloud + troubleshooting Indices and tables diff --git a/ucloud/docs/source/installation/archlinux.rst b/ucloud/docs/source/installation/archlinux.rst deleted file mode 100644 index b2a7f86..0000000 --- a/ucloud/docs/source/installation/archlinux.rst +++ /dev/null @@ -1,25 +0,0 @@ -Arch Linux ----------- - -Requirements from Arch Linux -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: sh - :linenos: - - pacman -Syu qemu - - -Requirements from AUR -~~~~~~~~~~~~~~~~~~~~~ - -Use your favorite AUR manager to install the following packages: - -* etcd - - -Other -~~~~~ -Continue like Alpine for - -* uotp diff --git a/ucloud/docs/source/introduction.rst b/ucloud/docs/source/introduction.rst index eba2020..22a8ba5 100644 --- a/ucloud/docs/source/introduction.rst +++ b/ucloud/docs/source/introduction.rst @@ -23,45 +23,14 @@ different use cases: Architecture ------------ +We try to reuse existing components for ucloud. Generally speaking, +ucloud consist of a variety of daemons who handle specific tasks and +connect to a shared database. +All interactions with the clients are done through an API. +ucloud consists of the following components: -suuuuuuubsub -^^^^^^^^^^^^ - -and following a - -para -"""" - -para2? - -para2! -"""""" -mo moo! - -Introduction - -What is ucloud? -~~~~~~~~~~~~~~~ - - - -Tech Stack ----------- -* Python 3 as main language. -* Flask for APIs. -* JSON for specifications. -* QEMU (+ kvm acceleration) as hypervisor. -* etcd for key/value storage (specifically all metadata e.g Virtual Machine Specifications, Networks Specifications, Images Specifications etc.). -* CEPH for image storage. -* uotp for user authentication. -* netbox for IPAM. -* radvd for Router Advertisement. - - -Components ----------- * API * Scheduler * Host @@ -69,26 +38,21 @@ Components * Image Scanner * Metadata Server * VM Init Scripts (dubbed as ucloud-init)How does ucloud work? -===================== - -ucloud is separeted into 3 systems: - -1. The client side for using ucloud -2. The server side -3. The supporting infrastructure (databases, virtualisation, etc.) -Depending on your use case, select one of the following guides to -continue: +Tech Stack +---------- +The following technologies are utilised: -* I want to use -* I want to operate/run ucloud as a service +* Python 3 +* Flask +* QEMU as hypervisor +* etcd (key/value store) +* radvd for Router Advertisement -Architecture ------------- -Description of the ucloud architecture +Optional components: - -Authentication -~~~~~~~~~~~~~~ +* CEPH for distributed image storage +* uotp for user authentication +* netbox for IPAM diff --git a/ucloud/docs/source/introduction/howitworks.rst b/ucloud/docs/source/introduction/howitworks.rst deleted file mode 100644 index e3278f9..0000000 --- a/ucloud/docs/source/introduction/howitworks.rst +++ /dev/null @@ -1,24 +0,0 @@ -How does ucloud work? -===================== - -ucloud is separeted into 3 systems: - -1. The client side for using ucloud -2. The server side -3. The supporting infrastructure (databases, virtualisation, etc.) - - -Depending on your use case, select one of the following guides to -continue: - -* I want to use -* I want to operate/run ucloud as a service - - -Architecture ------------- -Description of the ucloud architecture - - -Authentication -~~~~~~~~~~~~~~ diff --git a/ucloud/docs/source/introduction/whatis.rst b/ucloud/docs/source/introduction/whatis.rst deleted file mode 100644 index 8f47acc..0000000 --- a/ucloud/docs/source/introduction/whatis.rst +++ /dev/null @@ -1,34 +0,0 @@ -What is ucloud? -=============== - -**Open** + **Simple** + **Easy to hack** + **IPv6 First** - -ucloud is an easy to use cloud management system. - - -It is an alternative to OpenStack, OpenNebula or Cloudstack. - -ucloud is the first cloud management system that puts IPv6 first. ucloud also has an integral ordering process that we missed in existing solutions. - -Tech Stack ----------- -* Python 3 as main language. -* Flask for APIs. -* JSON for specifications. -* QEMU (+ kvm acceleration) as hypervisor. -* etcd for key/value storage (specifically all metadata e.g Virtual Machine Specifications, Networks Specifications, Images Specifications etc.). -* CEPH for image storage. -* uotp for user authentication. -* netbox for IPAM. -* radvd for Router Advertisement. - - -Components ----------- -* API -* Scheduler -* Host -* File Scanner -* Image Scanner -* Metadata Server -* VM Init Scripts (dubbed as ucloud-init) \ No newline at end of file diff --git a/ucloud/docs/source/installation/alpine.rst b/ucloud/docs/source/setup-install.rst similarity index 88% rename from ucloud/docs/source/installation/alpine.rst rename to ucloud/docs/source/setup-install.rst index 87e71ff..421e6c7 100644 --- a/ucloud/docs/source/installation/alpine.rst +++ b/ucloud/docs/source/setup-install.rst @@ -1,20 +1,48 @@ -Installation -============ -This guides includes two type of installation +.. _setup-install: -* File System as Image Storage + Level 2 Network without IPAM and Routing -* CEPH as Image Storage + Level 2 Network with automatic IPAM and Routing - (using Router Advertisement + Netbox) +Installation of ucloud +====================== +To install ucloud, you will first need to install the requirements and +then ucloud itself. -The guide will explicitly mention a section/subsection if it is exclusive to any -one of the above mentioned scenario. +We describe the installation in x sections: + +* Installation overview +* Requirements on Alpine +* Installation on Arch Linux -.. note:: - The instructions assumes the following things - - * User is **root**. - * Base Directory is :file:`/root/`. +Installation overview +--------------------- + +ucloud requires the following components to run: + +* python3 +* an etcd cluster + + +Installation on Arch Linux +-------------------------- + +In Arch Linux, some packages can be installed from the regular +repositories, some packages need to be installed from AUR. + + +System packages +~~~~~~~~~~~~~~~ + +.. code-block:: sh + :linenos: + + pacman -Syu qemu + + +AUR packages +~~~~~~~~~~~~ +Use your favorite AUR manager to install the following packages: + +* etcd + Alpine ------ @@ -150,7 +178,7 @@ Install and configure ucloud git clone https://code.ungleich.ch/ucloud/ucloud.git cd ucloud - + pipenv --three --site-packages pipenv install @@ -293,4 +321,3 @@ your shell profile e.g *~/.profile* alias uotp='cd /root/uotp/ && pipenv run python app.py' and run :code:`source ~/.profile` - diff --git a/ucloud/docs/source/troubleshooting/installation-troubleshooting.rst b/ucloud/docs/source/troubleshooting.rst similarity index 100% rename from ucloud/docs/source/troubleshooting/installation-troubleshooting.rst rename to ucloud/docs/source/troubleshooting.rst diff --git a/ucloud/docs/source/user-guide.rst b/ucloud/docs/source/user-guide.rst index f2ea9b2..f4ce935 100644 --- a/ucloud/docs/source/user-guide.rst +++ b/ucloud/docs/source/user-guide.rst @@ -1,7 +1,7 @@ .. _user-guide: -Usage Guide For End Users -========================= +User Guide +========== Create VM --------- diff --git a/ucloud/docs/source/usage/how-to-create-an-os-image-for-ucloud.rst b/ucloud/docs/source/user-guide/how-to-create-an-os-image-for-ucloud.rst similarity index 100% rename from ucloud/docs/source/usage/how-to-create-an-os-image-for-ucloud.rst rename to ucloud/docs/source/user-guide/how-to-create-an-os-image-for-ucloud.rst From a31dd383436a0e10a499d7e699f6090a33896b99 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 7 Dec 2019 12:53:41 +0100 Subject: [PATCH 034/284] [docs] add "clean" target --- ucloud/docs/Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ucloud/docs/Makefile b/ucloud/docs/Makefile index a62df24..5e7ea85 100644 --- a/ucloud/docs/Makefile +++ b/ucloud/docs/Makefile @@ -20,3 +20,6 @@ permissions: build build: $(SPHINXBUILD) "$(SOURCEDIR)" "$(BUILDDIR)" + +clean: + rm -rf $(BUILDDIR) From 424c0d27b2d1ab247d0d3ad6440851fee1c7f05f Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 7 Dec 2019 12:54:52 +0100 Subject: [PATCH 035/284] update readme --- README.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/README.md b/README.md index b36fe90..0e32f57 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,3 @@ # ucloud -**Open** + **Simple** + **Easy to hack** + **IPv6 First**. - -ucloud is an easy to use cloud management system. - -It is an alternative to OpenStack, OpenNebula or Cloudstack. - -ucloud is the first cloud management system that puts IPv6 first. ucloud also has an integral ordering process that we missed in existing solutions. \ No newline at end of file +Checkout https://ungleich.ch/ucloud/ for the documentation of ucloud. From ba9ac4c75430ef56c36993613d09519979e0fe43 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 7 Dec 2019 12:58:51 +0100 Subject: [PATCH 036/284] add shell wrapper for running ucloud from checkout --- bin/ucloud | 84 +++++++++++++++++--------------------------------- scripts/ucloud | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 55 deletions(-) mode change 100644 => 100755 bin/ucloud create mode 100644 scripts/ucloud diff --git a/bin/ucloud b/bin/ucloud old mode 100644 new mode 100755 index 7be6b24..238789e --- a/bin/ucloud +++ b/bin/ucloud @@ -1,59 +1,33 @@ -#!/usr/bin/env python3 +#!/bin/sh +# -*- coding: utf-8 -*- +# +# 2012-2019 Nico Schottelius (nico-ucloud at schottelius.org) +# +# This file is part of ucloud. +# +# ucloud is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ucloud is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ucloud. If not, see . +# +# -import argparse -import multiprocessing as mp -import logging +# Wrapper for real script to allow execution from checkout +dir=${0%/*} -from os.path import join as join_path -from ucloud.sanity_checks import check +# Ensure version is present - the bundled/shipped version contains a static version, +# the git version contains a dynamic version +printf "VERSION = \"%s\"\n" "$(git describe)" > ${dir}/ucloud/version.py -if __name__ == "__main__": - arg_parser = argparse.ArgumentParser(prog='ucloud', - description='Open Source Cloud Management Software') - arg_parser.add_argument('component', - choices=['api', 'scheduler', 'host', - 'filescanner', 'imagescanner', - 'metadata']) - arg_parser.add_argument('component_args', nargs='*') - args = arg_parser.parse_args() +libdir=$(cd "${dir}/../" && pwd -P) +export PYTHONPATH="${libdir}" - logging.basicConfig( - level=logging.DEBUG, - filename=join_path("/", "etc", "ucloud", "log.txt"), - filemode="a", - format="%(name)s %(asctime)s: %(levelname)s - %(message)s", - datefmt="%d-%b-%y %H:%M:%S", - ) - try: - check() - - if args.component == 'api': - from ucloud.api.main import main - - main() - elif args.component == 'host': - from ucloud.host.main import main - - hostname = args.component_args - mp.set_start_method('spawn') - main(*hostname) - elif args.component == 'scheduler': - from ucloud.scheduler.main import main - - main() - elif args.component == 'filescanner': - from ucloud.filescanner.main import main - - main() - elif args.component == 'imagescanner': - from ucloud.imagescanner.main import main - - main() - elif args.component == 'metadata': - from ucloud.metadata.main import main - - main() - - except Exception as e: - logging.exception(e) - print(e) \ No newline at end of file +"$dir/../scripts/ucloud" "$@" diff --git a/scripts/ucloud b/scripts/ucloud new file mode 100644 index 0000000..7be6b24 --- /dev/null +++ b/scripts/ucloud @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 + +import argparse +import multiprocessing as mp +import logging + +from os.path import join as join_path +from ucloud.sanity_checks import check + +if __name__ == "__main__": + arg_parser = argparse.ArgumentParser(prog='ucloud', + description='Open Source Cloud Management Software') + arg_parser.add_argument('component', + choices=['api', 'scheduler', 'host', + 'filescanner', 'imagescanner', + 'metadata']) + arg_parser.add_argument('component_args', nargs='*') + args = arg_parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG, + filename=join_path("/", "etc", "ucloud", "log.txt"), + filemode="a", + format="%(name)s %(asctime)s: %(levelname)s - %(message)s", + datefmt="%d-%b-%y %H:%M:%S", + ) + try: + check() + + if args.component == 'api': + from ucloud.api.main import main + + main() + elif args.component == 'host': + from ucloud.host.main import main + + hostname = args.component_args + mp.set_start_method('spawn') + main(*hostname) + elif args.component == 'scheduler': + from ucloud.scheduler.main import main + + main() + elif args.component == 'filescanner': + from ucloud.filescanner.main import main + + main() + elif args.component == 'imagescanner': + from ucloud.imagescanner.main import main + + main() + elif args.component == 'metadata': + from ucloud.metadata.main import main + + main() + + except Exception as e: + logging.exception(e) + print(e) \ No newline at end of file From 6596e482cae71f4fb2df23ab9405c6fcc58d90e9 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 7 Dec 2019 13:08:55 +0100 Subject: [PATCH 037/284] ignore version.py (generated dynamically) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 79b89a9..85c35fd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ logs.txt ucloud.egg-info default.etcd +ucloud/version.py From 57eaddb03f839fa940e11b05b32b50caae55473f Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 7 Dec 2019 13:09:12 +0100 Subject: [PATCH 038/284] fix checkout support --- bin/ucloud | 2 +- scripts/ucloud | 0 2 files changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 scripts/ucloud diff --git a/bin/ucloud b/bin/ucloud index 238789e..e178413 100755 --- a/bin/ucloud +++ b/bin/ucloud @@ -25,7 +25,7 @@ dir=${0%/*} # Ensure version is present - the bundled/shipped version contains a static version, # the git version contains a dynamic version -printf "VERSION = \"%s\"\n" "$(git describe)" > ${dir}/ucloud/version.py +printf "VERSION = \"%s\"\n" "$(git describe)" > ${dir}/../ucloud/version.py libdir=$(cd "${dir}/../" && pwd -P) export PYTHONPATH="${libdir}" diff --git a/scripts/ucloud b/scripts/ucloud old mode 100644 new mode 100755 From 34d9dc1f73ee7fb25953458f296bbe63d305f701 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 7 Dec 2019 13:23:48 +0100 Subject: [PATCH 039/284] [install] use scripts/ --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 88b85e5..d2bd004 100644 --- a/setup.py +++ b/setup.py @@ -31,5 +31,5 @@ setup(name='ucloud', 'etcd3_wrapper @ https://code.ungleich.ch/ungleich-public/etcd3_wrapper/repository/master/archive.tar.gz#egg=etcd3_wrapper', 'etcd3 @ https://github.com/kragniz/python-etcd3/tarball/master#egg=etcd3', ], - scripts=['bin/ucloud'], - zip_safe=False) \ No newline at end of file + scripts=['scripts/ucloud'], + zip_safe=False) From 40176d2eaf8d02ad13640ee139b3938923c89d63 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 7 Dec 2019 13:45:01 +0100 Subject: [PATCH 040/284] Allow non existing configuration file Fixes #1. --- .gitignore | 8 +++++++- setup.py | 2 +- ucloud/config.py | 9 +++++++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 85c35fd..82146fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,18 @@ .idea .vscode -__pycache__ ucloud/docs/build logs.txt ucloud.egg-info +# run artefacts default.etcd +__pycache__ + +# build artefacts ucloud/version.py +build/ +venv/ +dist/ diff --git a/setup.py b/setup.py index d2bd004..9a35f27 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ with open("README.md", "r") as fh: long_description = fh.read() setup(name='ucloud', - version='0.1', + version='0.0.1', description='All ucloud server components.', url='https://code.ungleich.ch/ucloud/ucloud', long_description=long_description, diff --git a/ucloud/config.py b/ucloud/config.py index d5e5b77..7c141a3 100644 --- a/ucloud/config.py +++ b/ucloud/config.py @@ -4,10 +4,15 @@ from ucloud.common.host import HostPool from ucloud.common.request import RequestPool from ucloud.common.vm import VmPool from ucloud.common.storage_handlers import FileSystemBasedImageStorageHandler, CEPHBasedImageStorageHandler -from decouple import Config, RepositoryEnv +from decouple import Config, RepositoryEnv, RepositoryEmpty -env_vars = Config(RepositoryEnv('/etc/ucloud/ucloud.conf')) +# Try importing config, but don't fail if it does not exist +try: + env_vars = Config(RepositoryEnv('/etc/ucloud/ucloud.conf')) +except FileNotFoundError: + env_vars = Config(RepositoryEmpty()) + etcd_wrapper_args = () etcd_wrapper_kwargs = { From 6d715e83483a09decb05c2420ca8cf85815e4386 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 7 Dec 2019 13:51:50 +0100 Subject: [PATCH 041/284] [config] setup default values to remove startup failures --- ucloud/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ucloud/config.py b/ucloud/config.py index 7c141a3..a2f523f 100644 --- a/ucloud/config.py +++ b/ucloud/config.py @@ -25,13 +25,13 @@ etcd_wrapper_kwargs = { etcd_client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) -host_pool = HostPool(etcd_client, env_vars.get('HOST_PREFIX')) -vm_pool = VmPool(etcd_client, env_vars.get('VM_PREFIX')) -request_pool = RequestPool(etcd_client, env_vars.get('REQUEST_PREFIX')) +host_pool = HostPool(etcd_client, env_vars.get('HOST_PREFIX', "hosts")) +vm_pool = VmPool(etcd_client, env_vars.get('VM_PREFIX', "vms")) +request_pool = RequestPool(etcd_client, env_vars.get('REQUEST_PREFIX', "requests")) running_vms = [] -__storage_backend = env_vars.get("STORAGE_BACKEND") +__storage_backend = env_vars.get("STORAGE_BACKEND", "filesystem") if __storage_backend == "filesystem": image_storage_handler = FileSystemBasedImageStorageHandler(vm_base=env_vars.get("VM_DIR"), image_base=env_vars.get("IMAGE_DIR")) From 9ae75f20e8280efbc6cd0a328e2590b82d7b2020 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 7 Dec 2019 14:01:44 +0100 Subject: [PATCH 042/284] Generate version from git Fixes #3 --- bin/gen-version | 29 +++++++++++++++++++++++++++++ bin/ucloud | 2 +- setup.py | 11 ++++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100755 bin/gen-version diff --git a/bin/gen-version b/bin/gen-version new file mode 100755 index 0000000..8f622b8 --- /dev/null +++ b/bin/gen-version @@ -0,0 +1,29 @@ +#!/bin/sh +# -*- coding: utf-8 -*- +# +# 2019 Nico Schottelius (nico-ucloud at schottelius.org) +# +# This file is part of ucloud. +# +# ucloud is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ucloud is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ucloud. If not, see . +# +# + + +# Wrapper for real script to allow execution from checkout +dir=${0%/*} + +# Ensure version is present - the bundled/shipped version contains a static version, +# the git version contains a dynamic version +printf "VERSION = \"%s\"\n" "$(git describe)" > ${dir}/../ucloud/version.py diff --git a/bin/ucloud b/bin/ucloud index e178413..ba337fd 100755 --- a/bin/ucloud +++ b/bin/ucloud @@ -25,7 +25,7 @@ dir=${0%/*} # Ensure version is present - the bundled/shipped version contains a static version, # the git version contains a dynamic version -printf "VERSION = \"%s\"\n" "$(git describe)" > ${dir}/../ucloud/version.py +${dir}/gen-version libdir=$(cd "${dir}/../" && pwd -P) export PYTHONPATH="${libdir}" diff --git a/setup.py b/setup.py index 9a35f27..14dffb7 100644 --- a/setup.py +++ b/setup.py @@ -3,8 +3,17 @@ from setuptools import setup, find_packages with open("README.md", "r") as fh: long_description = fh.read() +try: + import ucloud.version + version = ucloud.version.VERSION +except: + import subprocess + c = subprocess.run(["git", "describe"], capture_output=True) + version = c.stdout.decode("utf-8") + + setup(name='ucloud', - version='0.0.1', + version=version, description='All ucloud server components.', url='https://code.ungleich.ch/ucloud/ucloud', long_description=long_description, From 2244b94fd80b089605aa6d3fea634ebaa2740c6f Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 7 Dec 2019 14:10:16 +0100 Subject: [PATCH 043/284] Fix another UndefinedValueError: VM_DIR decouple.UndefinedValueError: VM_DIR not found. Declare it as envvar or define a default value. --- ucloud/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ucloud/config.py b/ucloud/config.py index a2f523f..a5b8f00 100644 --- a/ucloud/config.py +++ b/ucloud/config.py @@ -33,8 +33,8 @@ running_vms = [] __storage_backend = env_vars.get("STORAGE_BACKEND", "filesystem") if __storage_backend == "filesystem": - image_storage_handler = FileSystemBasedImageStorageHandler(vm_base=env_vars.get("VM_DIR"), - image_base=env_vars.get("IMAGE_DIR")) + image_storage_handler = FileSystemBasedImageStorageHandler(vm_base=env_vars.get("VM_DIR", "/tmp/ucloud-vms"), + image_base=env_vars.get("IMAGE_DIR", "/tmp/ucloud-images")) elif __storage_backend == "ceph": image_storage_handler = CEPHBasedImageStorageHandler(vm_base="ssd", image_base="ssd") else: From f9dbdc730a1ab16813de47b0c48b324bdea5960e Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 7 Dec 2019 14:15:48 +0100 Subject: [PATCH 044/284] Remove logging configuration Leave it to the OS/env to set this up. Fixes #6 --- scripts/ucloud | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/scripts/ucloud b/scripts/ucloud index 7be6b24..d700d79 100755 --- a/scripts/ucloud +++ b/scripts/ucloud @@ -17,13 +17,6 @@ if __name__ == "__main__": arg_parser.add_argument('component_args', nargs='*') args = arg_parser.parse_args() - logging.basicConfig( - level=logging.DEBUG, - filename=join_path("/", "etc", "ucloud", "log.txt"), - filemode="a", - format="%(name)s %(asctime)s: %(levelname)s - %(message)s", - datefmt="%d-%b-%y %H:%M:%S", - ) try: check() @@ -56,4 +49,4 @@ if __name__ == "__main__": except Exception as e: logging.exception(e) - print(e) \ No newline at end of file + print(e) From 9517e73233415c6c76438c024db8ea718166455f Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 7 Dec 2019 14:25:21 +0100 Subject: [PATCH 045/284] Migrate sanity_check.py into the respective daemons --- scripts/ucloud | 2 -- ucloud/host/main.py | 9 +++++++++ ucloud/imagescanner/main.py | 13 +++++++++++++ ucloud/sanity_checks.py | 33 --------------------------------- 4 files changed, 22 insertions(+), 35 deletions(-) delete mode 100644 ucloud/sanity_checks.py diff --git a/scripts/ucloud b/scripts/ucloud index d700d79..1bb752b 100755 --- a/scripts/ucloud +++ b/scripts/ucloud @@ -18,8 +18,6 @@ if __name__ == "__main__": args = arg_parser.parse_args() try: - check() - if args.component == 'api': from ucloud.api.main import main diff --git a/ucloud/host/main.py b/ucloud/host/main.py index ccf0a8d..ca68351 100755 --- a/ucloud/host/main.py +++ b/ucloud/host/main.py @@ -71,8 +71,17 @@ def maintenance(host): if _vm: running_vms.remove(_vm) +def check(): + if env_vars.get('STORAGE_BACKEND') == 'filesystem' and not isdir(env_vars.get('VM_DIR')): + print("You have set STORAGE_BACKEND to filesystem. So, the vm directory mentioned" + " in .env file must exists. But, it don't.") + sys.exit(1) + + def main(hostname): + check() + heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(hostname,)) host_pool = HostPool(etcd_client, env_vars.get('HOST_PREFIX')) diff --git a/ucloud/imagescanner/main.py b/ucloud/imagescanner/main.py index 20ce9d5..6ff01f8 100755 --- a/ucloud/imagescanner/main.py +++ b/ucloud/imagescanner/main.py @@ -18,6 +18,19 @@ def qemu_img_type(path): qemu_img_info = json.loads(qemu_img_info.decode("utf-8")) return qemu_img_info["format"] +def check(): + """ check whether settings are sane, refuse to start if they aren't """ + if env_vars.get('STORAGE_BACKEND') == 'filesystem' and not isdir(env_vars.get('IMAGE_DIR')): + print("You have set STORAGE_BACKEND to filesystem, but " + "{} does not exist. Refusing to start".format(env_vars.get('IMAGE_DIR'))) + sys.exit(1) + + try: + subprocess.check_output(['which', 'qemu-img']) + except Exception: + print("qemu-img missing") + sys.exit(1) + def main(): # We want to get images entries that requests images to be created diff --git a/ucloud/sanity_checks.py b/ucloud/sanity_checks.py deleted file mode 100644 index 143f767..0000000 --- a/ucloud/sanity_checks.py +++ /dev/null @@ -1,33 +0,0 @@ -import sys -import subprocess as sp - -from os.path import isdir -from ucloud.config import env_vars - - -def check(): - ######################### - # ucloud-image-scanner # - ######################### - if env_vars.get('STORAGE_BACKEND') == 'filesystem' and not isdir(env_vars.get('IMAGE_DIR')): - print("You have set STORAGE_BACKEND to filesystem. So," - "the {} must exists. But, it don't".format(env_vars.get('IMAGE_DIR'))) - sys.exit(1) - - try: - sp.check_output(['which', 'qemu-img']) - except Exception: - print("qemu-img missing") - sys.exit(1) - - ############### - # ucloud-host # - ############### - - if env_vars.get('STORAGE_BACKEND') == 'filesystem' and not isdir(env_vars.get('VM_DIR')): - print("You have set STORAGE_BACKEND to filesystem. So, the vm directory mentioned" - " in .env file must exists. But, it don't.") - sys.exit(1) - -if __name__ == "__main__": - check() \ No newline at end of file From cfb09c29de86f3cf7f9a5d442092d54c7aa9797e Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 8 Dec 2019 12:28:25 +0100 Subject: [PATCH 046/284] simplify main script --- scripts/ucloud | 47 ++++++++++++++++------------------------------- 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/scripts/ucloud b/scripts/ucloud index 1bb752b..277bf2e 100755 --- a/scripts/ucloud +++ b/scripts/ucloud @@ -1,49 +1,34 @@ #!/usr/bin/env python3 import argparse -import multiprocessing as mp import logging +import importlib -from os.path import join as join_path -from ucloud.sanity_checks import check +# For the exception +import decouple +import sys + +COMMANDS = ['api', 'scheduler', 'host', 'filescanner', 'imagescanner', 'metadata'] if __name__ == "__main__": + log = logging.getLogger("ucloud") + arg_parser = argparse.ArgumentParser(prog='ucloud', description='Open Source Cloud Management Software') - arg_parser.add_argument('component', - choices=['api', 'scheduler', 'host', - 'filescanner', 'imagescanner', - 'metadata']) + arg_parser.add_argument('component', choices=COMMANDS) arg_parser.add_argument('component_args', nargs='*') args = arg_parser.parse_args() try: - if args.component == 'api': - from ucloud.api.main import main + name = args.component - main() - elif args.component == 'host': - from ucloud.host.main import main + mod = importlib.import_module("ucloud.{}.main".format(name)) + main = getattr(mod, "main") + main() - hostname = args.component_args - mp.set_start_method('spawn') - main(*hostname) - elif args.component == 'scheduler': - from ucloud.scheduler.main import main - - main() - elif args.component == 'filescanner': - from ucloud.filescanner.main import main - - main() - elif args.component == 'imagescanner': - from ucloud.imagescanner.main import main - - main() - elif args.component == 'metadata': - from ucloud.metadata.main import main - - main() + except decouple.UndefinedValueError as e: + print(e) + sys.exit(1) except Exception as e: logging.exception(e) From e459434b9177105a223409c3cd60ec37f1cd594a Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 8 Dec 2019 12:58:26 +0100 Subject: [PATCH 047/284] add sample ucloud.conf --- conf/ucloud.conf | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 conf/ucloud.conf diff --git a/conf/ucloud.conf b/conf/ucloud.conf new file mode 100644 index 0000000..7f83d80 --- /dev/null +++ b/conf/ucloud.conf @@ -0,0 +1,12 @@ +# This section contains default values for all other sections +[DEFAULT] + +NETWORK_PREFIX = moo + +[api] +NETWORK_PREFIX = foo + +[woo] +NETWORK_PREFIX = foo + +[noval] \ No newline at end of file From 6d0ce65f5c3124d0f038b767df70d08c37540fec Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 8 Dec 2019 12:59:18 +0100 Subject: [PATCH 048/284] begin to switch to configparser To not have unwanted environment influence --- scripts/ucloud | 17 ++++++++++------- ucloud/config.py | 40 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/scripts/ucloud b/scripts/ucloud index 277bf2e..b8ef32d 100755 --- a/scripts/ucloud +++ b/scripts/ucloud @@ -3,10 +3,8 @@ import argparse import logging import importlib - -# For the exception -import decouple import sys +import os COMMANDS = ['api', 'scheduler', 'host', 'filescanner', 'imagescanner', 'metadata'] @@ -15,20 +13,25 @@ if __name__ == "__main__": arg_parser = argparse.ArgumentParser(prog='ucloud', description='Open Source Cloud Management Software') + arg_parser.add_argument('-c', '--conf-dir', help="Configuration directory") arg_parser.add_argument('component', choices=COMMANDS) arg_parser.add_argument('component_args', nargs='*') args = arg_parser.parse_args() try: name = args.component - mod = importlib.import_module("ucloud.{}.main".format(name)) main = getattr(mod, "main") + + if args.conf_dir: + print("setting conf") + os.environ['UCLOUD_CONF_DIR'] = args.conf_dir + main() - except decouple.UndefinedValueError as e: - print(e) - sys.exit(1) + # except decouple.UndefinedValueError as e: + # print(e) + # sys.exit(1) except Exception as e: logging.exception(e) diff --git a/ucloud/config.py b/ucloud/config.py index a5b8f00..b508f56 100644 --- a/ucloud/config.py +++ b/ucloud/config.py @@ -4,14 +4,46 @@ from ucloud.common.host import HostPool from ucloud.common.request import RequestPool from ucloud.common.vm import VmPool from ucloud.common.storage_handlers import FileSystemBasedImageStorageHandler, CEPHBasedImageStorageHandler + from decouple import Config, RepositoryEnv, RepositoryEmpty +# Replacing decouple inline +import configparser +import os +import os.path + +import logging + +log = logging.getLogger("ucloud.config") + + +conf_name = "ucloud.conf" + +try: + conf_dir = os.environ["UCLOUD_CONF_DIR"] +except KeyError: + conf_dir = "/etc/ucloud" + +config_file = os.path.join(conf_dir, conf_name) + +config = configparser.ConfigParser() + +try: + with open(config_file, "r") as conf_fd: + conf.read(conf_fd) +except FileNotFoundError: + log.warn("Configuration file not found - using defaults") + + +# Compatibility to old code +env_vars = config + # Try importing config, but don't fail if it does not exist -try: - env_vars = Config(RepositoryEnv('/etc/ucloud/ucloud.conf')) -except FileNotFoundError: - env_vars = Config(RepositoryEmpty()) +# try: +# env_vars = Config(RepositoryEnv('/etc/ucloud/ucloud.conf')) +# except FileNotFoundError: +# env_vars = Config(RepositoryEmpty()) etcd_wrapper_args = () From a4bedb01f60346b9abde13552bfdaef5fbbc4638 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 8 Dec 2019 13:00:42 +0100 Subject: [PATCH 049/284] [api] begin to move to configparser --- ucloud/api/create_image_store.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ucloud/api/create_image_store.py b/ucloud/api/create_image_store.py index 17fa63c..259b9c8 100755 --- a/ucloud/api/create_image_store.py +++ b/ucloud/api/create_image_store.py @@ -3,7 +3,7 @@ import os from uuid import uuid4 -from ucloud.config import etcd_client, env_vars +from ucloud.config import etcd_client, config data = { "is_public": True, @@ -13,4 +13,4 @@ data = { "attributes": {"list": [], "key": [], "pool": "images"}, } -etcd_client.put(os.path.join(env_vars.get('IMAGE_STORE_PREFIX'), uuid4().hex), json.dumps(data)) +etcd_client.put(os.path.join(config['api']['IMAGE_STORE_PREFIX'], uuid4().hex), json.dumps(data)) From cdbfb96e712f51970729bdd999aeb531ba072336 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 8 Dec 2019 13:09:52 +0100 Subject: [PATCH 050/284] [api] config updates and add default values --- conf/ucloud.conf | 6 ++++++ ucloud/api/common_fields.py | 5 ++--- ucloud/api/helper.py | 26 +++++++++++++------------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/conf/ucloud.conf b/conf/ucloud.conf index 7f83d80..0ebb705 100644 --- a/conf/ucloud.conf +++ b/conf/ucloud.conf @@ -1,8 +1,14 @@ # This section contains default values for all other sections [DEFAULT] +AUTH_NAME = "replace me" +AUTH_SEED = "replace me" +AUTH_REALM = "replace me" + NETWORK_PREFIX = moo +OTP_VERIFY_ENDPOINT = verify/ + [api] NETWORK_PREFIX = foo diff --git a/ucloud/api/common_fields.py b/ucloud/api/common_fields.py index e9903ac..1ceb1b0 100755 --- a/ucloud/api/common_fields.py +++ b/ucloud/api/common_fields.py @@ -1,7 +1,6 @@ import os -from ucloud.config import etcd_client, env_vars - +from ucloud.config import etcd_client, config class Optional: pass @@ -48,6 +47,6 @@ class VmUUIDField(Field): self.validation = self.vm_uuid_validation def vm_uuid_validation(self): - r = etcd_client.get(os.path.join(env_vars.get('VM_PREFIX'), self.uuid)) + r = etcd_client.get(os.path.join(config['api']['VM_PREFIX'], self.uuid)) if not r: self.add_error("VM with uuid {} does not exists".format(self.uuid)) diff --git a/ucloud/api/helper.py b/ucloud/api/helper.py index 63d2f90..6735f05 100755 --- a/ucloud/api/helper.py +++ b/ucloud/api/helper.py @@ -7,15 +7,15 @@ import requests from pyotp import TOTP -from ucloud.config import vm_pool, env_vars +from ucloud.config import vm_pool, config def check_otp(name, realm, token): try: data = { - "auth_name": env_vars.get("AUTH_NAME"), - "auth_token": TOTP(env_vars.get("AUTH_SEED")).now(), - "auth_realm": env_vars.get("AUTH_REALM"), + "auth_name": config['api']["AUTH_NAME"], + "auth_token": TOTP(config['api']["AUTH_SEED"]).now(), + "auth_realm": config['api']["AUTH_REALM"], "name": name, "realm": realm, "token": token, @@ -25,8 +25,8 @@ def check_otp(name, realm, token): response = requests.post( "{OTP_SERVER}{OTP_VERIFY_ENDPOINT}".format( - OTP_SERVER=env_vars.get("OTP_SERVER", ""), - OTP_VERIFY_ENDPOINT=env_vars.get("OTP_VERIFY_ENDPOINT", "verify/"), + OTP_SERVER=config['api']["OTP_SERVER"], + OTP_VERIFY_ENDPOINT=config['api']["OTP_VERIFY_ENDPOINT"] ), json=data, ) @@ -35,7 +35,7 @@ def check_otp(name, realm, token): def resolve_vm_name(name, owner): """Return UUID of Virtual Machine of name == name and owner == owner - + Input: name of vm, owner of vm. Output: uuid of vm if found otherwise None """ @@ -54,7 +54,7 @@ def resolve_vm_name(name, owner): def resolve_image_name(name, etcd_client): """Return image uuid given its name and its store - + * If the provided name is not in correct format i.e {store_name}:{image_name} return ValueError * If no such image found then return KeyError @@ -70,9 +70,9 @@ def resolve_image_name(name, etcd_client): """ Examples, where it would work and where it would raise exception "images:alpine" --> ["images", "alpine"] - + "images" --> ["images"] it would raise Exception as non enough value to unpack - + "images:alpine:meow" --> ["images", "alpine", "meow"] it would raise Exception as too many values to unpack """ @@ -80,7 +80,7 @@ def resolve_image_name(name, etcd_client): except Exception: raise ValueError("Image name not in correct format i.e {store_name}:{image_name}") - images = etcd_client.get_prefix(env_vars.get('IMAGE_PREFIX'), value_in_json=True) + images = etcd_client.get_prefix(config['api']['IMAGE_PREFIX'], value_in_json=True) # Try to find image with name == image_name and store_name == store_name try: @@ -119,14 +119,14 @@ def generate_mac(uaa=False, multicast=False, oui=None, separator=':', byte_fmt=' def get_ip_addr(mac_address, device): """Return IP address of a device provided its mac address / link local address and the device with which it is connected. - + For Example, if we call get_ip_addr(mac_address="52:54:00:12:34:56", device="br0") the following two scenarios can happen 1. It would return None if we can't be able to find device whose mac_address is equal to the arg:mac_address or the mentioned arg:device does not exists or the ip address we found is local. 2. It would return ip_address of device whose mac_address is equal to arg:mac_address - and is connected/neighbor of arg:device + and is connected/neighbor of arg:device """ try: output = sp.check_output(['ip', '-6', 'neigh', 'show', 'dev', device], stderr=sp.PIPE) From 7486fafbaafc7d7d0db5f9d2d21b2e1d386fe105 Mon Sep 17 00:00:00 2001 From: llnu Date: Sun, 8 Dec 2019 13:23:26 +0100 Subject: [PATCH 051/284] [scheduler] refactored from env_vars to config --- ucloud/scheduler/main.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ucloud/scheduler/main.py b/ucloud/scheduler/main.py index e2c975a..9ebc4c8 100755 --- a/ucloud/scheduler/main.py +++ b/ucloud/scheduler/main.py @@ -6,7 +6,7 @@ from ucloud.common.request import RequestEntry, RequestType from ucloud.config import etcd_client -from ucloud.config import host_pool, request_pool, vm_pool, env_vars +from ucloud.config import host_pool, request_pool, vm_pool, config from .helper import (get_suitable_host, dead_host_mitigation, dead_host_detection, assign_host, NoSuitableHostFound) from . import logger @@ -18,8 +18,8 @@ def main(): pending_vms = [] for request_iterator in [ - etcd_client.get_prefix(env_vars.get('REQUEST_PREFIX'), value_in_json=True), - etcd_client.watch_prefix(env_vars.get('REQUEST_PREFIX'), timeout=5, value_in_json=True), + etcd_client.get_prefix(config['etcd']['REQUEST_PREFIX'], value_in_json=True), + etcd_client.watch_prefix(config['etcd']['REQUEST_PREFIX'], timeout=5, value_in_json=True), ]: for request_event in request_iterator: request_entry = RequestEntry(request_event) @@ -46,7 +46,7 @@ def main(): r = RequestEntry.from_scratch(type="ScheduleVM", uuid=pending_vm_entry.uuid, hostname=pending_vm_entry.hostname, - request_prefix=env_vars.get("REQUEST_PREFIX")) + request_prefix=config['etcd']["REQUEST_PREFIX"]) request_pool.put(r) elif request_entry.type == RequestType.ScheduleVM: @@ -72,7 +72,7 @@ def main(): r = RequestEntry.from_scratch(type=RequestType.InitVMMigration, uuid=request_entry.uuid, destination=request_entry.destination, - request_prefix=env_vars.get("REQUEST_PREFIX")) + request_prefix=config['etcd']["REQUEST_PREFIX"]) request_pool.put(r) # If the Request is about a VM that just want to get started/created From 787b2363058e97696b4ce6d8bc514115b979e4f4 Mon Sep 17 00:00:00 2001 From: llnu Date: Sun, 8 Dec 2019 13:25:03 +0100 Subject: [PATCH 052/284] fixed brackets --- ucloud/scheduler/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ucloud/scheduler/main.py b/ucloud/scheduler/main.py index 9ebc4c8..33e94f2 100755 --- a/ucloud/scheduler/main.py +++ b/ucloud/scheduler/main.py @@ -46,7 +46,7 @@ def main(): r = RequestEntry.from_scratch(type="ScheduleVM", uuid=pending_vm_entry.uuid, hostname=pending_vm_entry.hostname, - request_prefix=config['etcd']["REQUEST_PREFIX"]) + request_prefix=config['etcd']['REQUEST_PREFIX']) request_pool.put(r) elif request_entry.type == RequestType.ScheduleVM: @@ -72,7 +72,7 @@ def main(): r = RequestEntry.from_scratch(type=RequestType.InitVMMigration, uuid=request_entry.uuid, destination=request_entry.destination, - request_prefix=config['etcd']["REQUEST_PREFIX"]) + request_prefix=config['etcd']['REQUEST_PREFIX']) request_pool.put(r) # If the Request is about a VM that just want to get started/created From 76f63633ca84fff61147e0537430f998cce09c68 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 8 Dec 2019 13:29:24 +0100 Subject: [PATCH 053/284] [api] done -> configparser --- conf/ucloud.conf | 21 +++++++++++++--- ucloud/api/main.py | 56 +++++++++++++++++++++---------------------- ucloud/api/schemas.py | 12 +++++----- 3 files changed, 52 insertions(+), 37 deletions(-) diff --git a/conf/ucloud.conf b/conf/ucloud.conf index 0ebb705..60d5042 100644 --- a/conf/ucloud.conf +++ b/conf/ucloud.conf @@ -12,7 +12,22 @@ OTP_VERIFY_ENDPOINT = verify/ [api] NETWORK_PREFIX = foo -[woo] -NETWORK_PREFIX = foo +[network] +PREFIX_LENGTH = 64 +PREFIX = 2001:db8::/48 -[noval] \ No newline at end of file +[netbox] +NETBOX_URL = https://replace-me.example.com +NETBOX_TOKEN = replace me + +[etcd] + +FILE_PREFIX = file/ +HOST_PREFIx = host/ +IMAGE_PREFIX = image/ +IMAGE_STORE_PREFIX = imagestore/ + +NETWORK_PREFIX = network/ +REQUEST_PREFIX = request/ +USER_PREFIX = user/ +VM_PREFIX = vm/ diff --git a/ucloud/api/main.py b/ucloud/api/main.py index 1475fb0..d3ddd5d 100644 --- a/ucloud/api/main.py +++ b/ucloud/api/main.py @@ -10,7 +10,7 @@ from flask_restful import Resource, Api from ucloud.common import counters from ucloud.common.vm import VMStatus from ucloud.common.request import RequestEntry, RequestType -from ucloud.config import (etcd_client, request_pool, vm_pool, host_pool, env_vars, image_storage_handler) +from ucloud.config import (etcd_client, request_pool, vm_pool, host_pool, config, image_storage_handler) from . import schemas from .helper import generate_mac, mac2ipv6 from . import logger @@ -28,7 +28,7 @@ class CreateVM(Resource): validator = schemas.CreateVMSchema(data) if validator.is_valid(): vm_uuid = uuid4().hex - vm_key = join_path(env_vars.get("VM_PREFIX"), vm_uuid) + vm_key = join_path(config['api']["VM_PREFIX"), vm_uuid) specs = { "cpu": validator.specs["cpu"], "ram": validator.specs["ram"], @@ -56,7 +56,7 @@ class CreateVM(Resource): # Create ScheduleVM Request r = RequestEntry.from_scratch( type=RequestType.ScheduleVM, uuid=vm_uuid, - request_prefix=env_vars.get("REQUEST_PREFIX") + request_prefix=config['api']["REQUEST_PREFIX") ) request_pool.put(r) @@ -71,7 +71,7 @@ class VmStatus(Resource): validator = schemas.VMStatusSchema(data) if validator.is_valid(): vm = vm_pool.get( - join_path(env_vars.get("VM_PREFIX"), data["uuid"]) + join_path(config['api']["VM_PREFIX"), data["uuid"]) ) vm_value = vm.value.copy() vm_value["ip"] = [] @@ -79,7 +79,7 @@ class VmStatus(Resource): network_name, mac, tap = network_mac_and_tap network = etcd_client.get( join_path( - env_vars.get("NETWORK_PREFIX"), + config['api']["NETWORK_PREFIX"), data["name"], network_name, ), @@ -100,7 +100,7 @@ class CreateImage(Resource): validator = schemas.CreateImageSchema(data) if validator.is_valid(): file_entry = etcd_client.get( - join_path(env_vars.get("FILE_PREFIX"), data["uuid"]) + join_path(config['api']["FILE_PREFIX"), data["uuid"]) ) file_entry_value = json.loads(file_entry.value) @@ -113,7 +113,7 @@ class CreateImage(Resource): "visibility": "public", } etcd_client.put( - join_path(env_vars.get("IMAGE_PREFIX"), data["uuid"]), + join_path(config['etcd']["IMAGE_PREFIX"), data["uuid"]), json.dumps(image_entry_json), ) @@ -125,7 +125,7 @@ class ListPublicImages(Resource): @staticmethod def get(): images = etcd_client.get_prefix( - env_vars.get("IMAGE_PREFIX"), value_in_json=True + config['etcd']["IMAGE_PREFIX"), value_in_json=True ) r = { "images": [] @@ -148,7 +148,7 @@ class VMAction(Resource): if validator.is_valid(): vm_entry = vm_pool.get( - join_path(env_vars.get("VM_PREFIX"), data["uuid"]) + join_path(config['etcd']["VM_PREFIX"), data["uuid"]) ) action = data["action"] @@ -172,7 +172,7 @@ class VMAction(Resource): type="{}VM".format(action.title()), uuid=data["uuid"], hostname=vm_entry.hostname, - request_prefix=env_vars.get("REQUEST_PREFIX") + request_prefix=config['etcd']["REQUEST_PREFIX"] ) request_pool.put(r) return {"message": "VM {} Queued".format(action.title())}, 200 @@ -193,10 +193,10 @@ class VMMigration(Resource): type=RequestType.ScheduleVM, uuid=vm.uuid, destination=join_path( - env_vars.get("HOST_PREFIX"), validator.destination.value + config['etcd']["HOST_PREFIX"], validator.destination.value ), migration=True, - request_prefix=env_vars.get("REQUEST_PREFIX") + request_prefix=config['etcd']["REQUEST_PREFIX"] ) request_pool.put(r) return {"message": "VM Migration Initialization Queued"}, 200 @@ -212,7 +212,7 @@ class ListUserVM(Resource): if validator.is_valid(): vms = etcd_client.get_prefix( - env_vars.get("VM_PREFIX"), value_in_json=True + config['etcd']["VM_PREFIX"], value_in_json=True ) return_vms = [] user_vms = filter(lambda v: v.value["owner"] == data["name"], vms) @@ -246,7 +246,7 @@ class ListUserFiles(Resource): if validator.is_valid(): files = etcd_client.get_prefix( - env_vars.get("FILE_PREFIX"), value_in_json=True + config['etcd']["FILE_PREFIX"], value_in_json=True ) return_files = [] user_files = list( @@ -270,7 +270,7 @@ class CreateHost(Resource): data = request.json validator = schemas.CreateHostSchema(data) if validator.is_valid(): - host_key = join_path(env_vars.get("HOST_PREFIX"), uuid4().hex) + host_key = join_path(config['etcd']["HOST_PREFIX"], uuid4().hex) host_entry = { "specs": data["specs"], "hostname": data["hostname"], @@ -309,7 +309,7 @@ class GetSSHKeys(Resource): # {user_prefix}/{realm}/{name}/key/ etcd_key = join_path( - env_vars.get('USER_PREFIX'), + config['etcd']['USER_PREFIX'], data["realm"], data["name"], "key", @@ -326,7 +326,7 @@ class GetSSHKeys(Resource): # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - env_vars.get('USER_PREFIX'), + config['etcd']['USER_PREFIX'), data["realm"], data["name"], "key", @@ -355,7 +355,7 @@ class AddSSHKey(Resource): # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - env_vars.get("USER_PREFIX"), + config['etcd']["USER_PREFIX"], data["realm"], data["name"], "key", @@ -385,7 +385,7 @@ class RemoveSSHKey(Resource): # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - env_vars.get("USER_PREFIX"), + config['etcd']["USER_PREFIX"], data["realm"], data["name"], "key", @@ -421,17 +421,17 @@ class CreateNetwork(Resource): } if validator.user.value: nb = pynetbox.api( - url=env_vars.get("NETBOX_URL"), - token=env_vars.get("NETBOX_TOKEN"), + url=config['netbox']["NETBOX_URL"], + token=config['netbox']["NETBOX_TOKEN"], ) nb_prefix = nb.ipam.prefixes.get( - prefix=env_vars.get("PREFIX") + prefix=config['network']["PREFIX"] ) prefix = nb_prefix.available_prefixes.create( data={ - "prefix_length": env_vars.get( - "PREFIX_LENGTH", cast=int + "prefix_length": config['network'][ + "PREFIX_LENGTH"] ), "description": '{}\'s network "{}"'.format( data["name"], data["network_name"] @@ -444,7 +444,7 @@ class CreateNetwork(Resource): network_entry["ipv6"] = "fd00::/64" network_key = join_path( - env_vars.get("NETWORK_PREFIX"), + config['network']["NETWORK_PREFIX"], data["name"], data["network_name"], ) @@ -462,7 +462,7 @@ class ListUserNetwork(Resource): if validator.is_valid(): prefix = join_path( - env_vars.get("NETWORK_PREFIX"), data["name"] + config['network']["NETWORK_PREFIX"], data["name"] ) networks = etcd_client.get_prefix(prefix, value_in_json=True) user_networks = [] @@ -498,7 +498,7 @@ api.add_resource(CreateNetwork, "/network/create") def main(): - image_stores = list(etcd_client.get_prefix(env_vars.get('IMAGE_STORE_PREFIX'), value_in_json=True)) + image_stores = list(etcd_client.get_prefix(config['etcd']['IMAGE_STORE_PREFIX'], value_in_json=True)) if len(image_stores) == 0: data = { "is_public": True, @@ -508,7 +508,7 @@ def main(): "attributes": {"list": [], "key": [], "pool": "images"}, } - etcd_client.put(join_path(env_vars.get('IMAGE_STORE_PREFIX'), uuid4().hex), json.dumps(data)) + etcd_client.put(join_path(config['etcd']['IMAGE_STORE_PREFIX'], uuid4().hex), json.dumps(data)) app.run(host="::", debug=True) diff --git a/ucloud/api/schemas.py b/ucloud/api/schemas.py index c4f60ca..23db184 100755 --- a/ucloud/api/schemas.py +++ b/ucloud/api/schemas.py @@ -21,7 +21,7 @@ import bitmath from ucloud.common.host import HostStatus from ucloud.common.vm import VMStatus -from ucloud.config import etcd_client, env_vars, vm_pool, host_pool +from ucloud.config import etcd_client, config, vm_pool, host_pool from . import helper from .common_fields import Field, VmUUIDField from .helper import check_otp, resolve_vm_name @@ -102,14 +102,14 @@ class CreateImageSchema(BaseSchema): super().__init__(data, fields) def file_uuid_validation(self): - file_entry = etcd_client.get(os.path.join(env_vars.get('FILE_PREFIX'), self.uuid.value)) + file_entry = etcd_client.get(os.path.join(config['etcd']['FILE_PREFIX'], self.uuid.value)) if file_entry is None: self.add_error( "Image File with uuid '{}' Not Found".format(self.uuid.value) ) def image_store_name_validation(self): - image_stores = list(etcd_client.get_prefix(env_vars.get('IMAGE_STORE_PREFIX'))) + image_stores = list(etcd_client.get_prefix(config['etcd']['IMAGE_STORE_PREFIX'])) image_store = next( filter( @@ -235,7 +235,7 @@ class CreateVMSchema(OTPSchema): if _network: for net in _network: - network = etcd_client.get(os.path.join(env_vars.get('NETWORK_PREFIX'), + network = etcd_client.get(os.path.join(config['etcd']['NETWORK_PREFIX'], self.name.value, net), value_in_json=True) if not network: @@ -400,7 +400,7 @@ class VmMigrationSchema(OTPSchema): if vm.status != VMStatus.running: self.add_error("Can't migrate non-running VM") - if vm.hostname == os.path.join(env_vars.get('HOST_PREFIX'), self.destination.value): + if vm.hostname == os.path.join(config['etcd']['HOST_PREFIX'], self.destination.value): self.add_error("Destination host couldn't be same as Source Host") @@ -442,7 +442,7 @@ class CreateNetwork(OTPSchema): super().__init__(data, fields=fields) def network_name_validation(self): - network = etcd_client.get(os.path.join(env_vars.get('NETWORK_PREFIX'), + network = etcd_client.get(os.path.join(config['etcd']['NETWORK_PREFIX'], self.name.value, self.network_name.value), value_in_json=True) From dd33b89941e447ea3dc9738c10aeb95a81c26dea Mon Sep 17 00:00:00 2001 From: llnu Date: Sun, 8 Dec 2019 13:31:56 +0100 Subject: [PATCH 054/284] [scheduler] helper.py refactored from env_vars to config --- ucloud/scheduler/helper.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ucloud/scheduler/helper.py b/ucloud/scheduler/helper.py index ba577d6..1754045 100755 --- a/ucloud/scheduler/helper.py +++ b/ucloud/scheduler/helper.py @@ -6,7 +6,7 @@ import bitmath from ucloud.common.host import HostStatus from ucloud.common.request import RequestEntry, RequestType from ucloud.common.vm import VMStatus -from ucloud.config import vm_pool, host_pool, request_pool, env_vars +from ucloud.config import vm_pool, host_pool, request_pool, config def accumulated_specs(vms_specs): @@ -106,7 +106,7 @@ def assign_host(vm): r = RequestEntry.from_scratch(type=RequestType.StartVM, uuid=vm.uuid, hostname=vm.hostname, - request_prefix=env_vars.get("REQUEST_PREFIX")) + request_prefix=config['etcd']['REQUEST_PREFIX']) request_pool.put(r) vm.log.append("VM scheduled for starting") From 431a6f6d9bc15ccb4bd479d21ec07391ea05fe57 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 8 Dec 2019 13:32:06 +0100 Subject: [PATCH 055/284] [metadata] -> configparser --- ucloud/metadata/main.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ucloud/metadata/main.py b/ucloud/metadata/main.py index e7cb33b..9281d7c 100644 --- a/ucloud/metadata/main.py +++ b/ucloud/metadata/main.py @@ -3,7 +3,7 @@ import os from flask import Flask, request from flask_restful import Resource, Api -from ucloud.config import etcd_client, env_vars, vm_pool +from ucloud.config import etcd_client, config, vm_pool app = Flask(__name__) api = Api(app) @@ -43,9 +43,7 @@ class Root(Resource): if not data: return {'message': 'Metadata for such VM does not exists.'}, 404 else: - - # {env_vars.get('USER_PREFIX')}/{realm}/{name}/key - etcd_key = os.path.join(env_vars.get('USER_PREFIX'), data.value['owner_realm'], + etcd_key = os.path.join(config['etcd']['USER_PREFIX'], data.value['owner_realm'], data.value['owner'], 'key') etcd_entry = etcd_client.get_prefix(etcd_key, value_in_json=True) user_personal_ssh_keys = [key.value for key in etcd_entry] From c37bf19f9215929409cb1d2387120d3515644b41 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 8 Dec 2019 13:36:45 +0100 Subject: [PATCH 056/284] ++conf --- conf/ucloud.conf | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/conf/ucloud.conf b/conf/ucloud.conf index 60d5042..f2b681a 100644 --- a/conf/ucloud.conf +++ b/conf/ucloud.conf @@ -1,4 +1,5 @@ # This section contains default values for all other sections +# [DEFAULT] AUTH_NAME = "replace me" @@ -21,9 +22,16 @@ NETBOX_URL = https://replace-me.example.com NETBOX_TOKEN = replace me [etcd] +ETCD_URL = localhost +ETCD_PORT = 2379 + +CA_CERT = changeme +CERT_CERT = changeme +CERT_KEY = changeme + FILE_PREFIX = file/ -HOST_PREFIx = host/ +HOST_PREFIx = hosts IMAGE_PREFIX = image/ IMAGE_STORE_PREFIX = imagestore/ From 608d1eb28006ba5cf70786275ec3caa026a38394 Mon Sep 17 00:00:00 2001 From: llnu Date: Sun, 8 Dec 2019 13:41:32 +0100 Subject: [PATCH 057/284] [host] main.py refactored from env_vars to config --- ucloud/host/main.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ucloud/host/main.py b/ucloud/host/main.py index ca68351..0ca345b 100755 --- a/ucloud/host/main.py +++ b/ucloud/host/main.py @@ -8,7 +8,7 @@ from ucloud.common.request import RequestEntry, RequestType from ucloud.config import (vm_pool, request_pool, etcd_client, running_vms, etcd_wrapper_args, etcd_wrapper_kwargs, - HostPool, env_vars) + HostPool, config) from .helper import find_free_port from . import virtualmachine @@ -18,7 +18,7 @@ from ucloud.host import logger def update_heartbeat(hostname): """Update Last HeartBeat Time for :param hostname: in etcd""" client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) - host_pool = HostPool(client, env_vars.get('HOST_PREFIX')) + host_pool = HostPool(client, config['etcd']['HOST_PREFIX']) this_host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) while True: @@ -72,7 +72,7 @@ def maintenance(host): running_vms.remove(_vm) def check(): - if env_vars.get('STORAGE_BACKEND') == 'filesystem' and not isdir(env_vars.get('VM_DIR')): + if config['etcd']['STORAGE_BACKEND'] == 'filesystem' and not isdir(config['etcd']['VM_DIR']): print("You have set STORAGE_BACKEND to filesystem. So, the vm directory mentioned" " in .env file must exists. But, it don't.") sys.exit(1) @@ -84,7 +84,7 @@ def main(hostname): heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(hostname,)) - host_pool = HostPool(etcd_client, env_vars.get('HOST_PREFIX')) + host_pool = HostPool(etcd_client, config['etcd']['HOST_PREFIX']) host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) assert host is not None, "No such host with name = {}".format(hostname) @@ -106,8 +106,8 @@ def main(hostname): # beat updating mechanism in separated thread for events_iterator in [ - etcd_client.get_prefix(env_vars.get('REQUEST_PREFIX'), value_in_json=True), - etcd_client.watch_prefix(env_vars.get('REQUEST_PREFIX'), timeout=10, value_in_json=True), + etcd_client.get_prefix(config['etcd']['REQUEST_PREFIX'], value_in_json=True), + etcd_client.watch_prefix(config['etcd']['REQUEST_PREFIX'], timeout=10, value_in_json=True), ]: for request_event in events_iterator: request_event = RequestEntry(request_event) From 72af426b3a3687e66597bad823d06310b403d907 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 8 Dec 2019 13:41:42 +0100 Subject: [PATCH 058/284] update config x2 --- conf/ucloud.conf | 8 ++++---- ucloud/config.py | 29 +++++++++++++---------------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/conf/ucloud.conf b/conf/ucloud.conf index f2b681a..7cd9725 100644 --- a/conf/ucloud.conf +++ b/conf/ucloud.conf @@ -35,7 +35,7 @@ HOST_PREFIx = hosts IMAGE_PREFIX = image/ IMAGE_STORE_PREFIX = imagestore/ -NETWORK_PREFIX = network/ -REQUEST_PREFIX = request/ -USER_PREFIX = user/ -VM_PREFIX = vm/ +NETWORK_PREFIX = networks +REQUEST_PREFIX = requests +USER_PREFIX = users +VM_PREFIX = vms diff --git a/ucloud/config.py b/ucloud/config.py index b508f56..b8b4dcf 100644 --- a/ucloud/config.py +++ b/ucloud/config.py @@ -35,10 +35,6 @@ except FileNotFoundError: log.warn("Configuration file not found - using defaults") -# Compatibility to old code -env_vars = config - - # Try importing config, but don't fail if it does not exist # try: # env_vars = Config(RepositoryEnv('/etc/ucloud/ucloud.conf')) @@ -48,26 +44,27 @@ env_vars = config etcd_wrapper_args = () etcd_wrapper_kwargs = { - 'host': env_vars.get('ETCD_URL', 'localhost'), - 'port': env_vars.get('ETCD_PORT', 2379), - 'ca_cert': env_vars.get('CA_CERT', None), - 'cert_cert': env_vars.get('CERT_CERT', None), - 'cert_key': env_vars.get('CERT_KEY', None) + 'host': config['etcd']['ETCD_URL'], + 'port': config['etcd']['ETCD_PORT'], + 'ca_cert': config['etcd']['CA_CERT'], + 'cert_cert': config['etcd']['CERT_CERT'], + 'cert_key': config['etcd']['CERT_KEY'] } etcd_client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) -host_pool = HostPool(etcd_client, env_vars.get('HOST_PREFIX', "hosts")) -vm_pool = VmPool(etcd_client, env_vars.get('VM_PREFIX', "vms")) -request_pool = RequestPool(etcd_client, env_vars.get('REQUEST_PREFIX', "requests")) +host_pool = HostPool(etcd_client, config['etcd']['HOST_PREFIX']) +vm_pool = VmPool(etcd_client, config['etcd']['VM_PREFIX']) +request_pool = RequestPool(etcd_client, config['etcd']['REQUEST_PREFIX']) running_vms = [] -__storage_backend = env_vars.get("STORAGE_BACKEND", "filesystem") +__storage_backend = config['storage']["STORAGE_BACKEND"] if __storage_backend == "filesystem": - image_storage_handler = FileSystemBasedImageStorageHandler(vm_base=env_vars.get("VM_DIR", "/tmp/ucloud-vms"), - image_base=env_vars.get("IMAGE_DIR", "/tmp/ucloud-images")) + image_storage_handler = FileSystemBasedImageStorageHandler(vm_base=config['storage']["VM_DIR"], + image_base=config['storage']["IMAGE_DIR"]) elif __storage_backend == "ceph": - image_storage_handler = CEPHBasedImageStorageHandler(vm_base="ssd", image_base="ssd") + image_storage_handler = CEPHBasedImageStorageHandler(vm_base=config['storage']["CEPH_VM_POOL"], + image_base=config['storage']["CEPH_IMAGE_POOL"]) else: raise Exception("Unknown Image Storage Handler") From 0d38a66a347a1550e9a8604e2df2877250833dd6 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 8 Dec 2019 13:46:24 +0100 Subject: [PATCH 059/284] add a wrapper to re install ucloud and then run it --- bin/ucloud-run-reinstall | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 bin/ucloud-run-reinstall diff --git a/bin/ucloud-run-reinstall b/bin/ucloud-run-reinstall new file mode 100644 index 0000000..b189bbc --- /dev/null +++ b/bin/ucloud-run-reinstall @@ -0,0 +1,29 @@ +#!/bin/sh +# -*- coding: utf-8 -*- +# +# 2012-2019 Nico Schottelius (nico-ucloud at schottelius.org) +# +# This file is part of ucloud. +# +# ucloud is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ucloud is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with ucloud. If not, see . +# +# + +# Wrapper for real script to allow execution from checkout +dir=${0%/*} + +${dir}/gen-version; +pip uninstall -y ucloud +python setup.py install +${dir}/ucloud "$@" From 012f3cb3b56a4dcf33f14492edda2f246c1be1c8 Mon Sep 17 00:00:00 2001 From: llnu Date: Sun, 8 Dec 2019 13:46:33 +0100 Subject: [PATCH 060/284] [conf] added storage dictionary --- conf/ucloud.conf | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/conf/ucloud.conf b/conf/ucloud.conf index 7cd9725..e0e3c27 100644 --- a/conf/ucloud.conf +++ b/conf/ucloud.conf @@ -39,3 +39,10 @@ NETWORK_PREFIX = networks REQUEST_PREFIX = requests USER_PREFIX = users VM_PREFIX = vms + +[storage] +STORAGE_BACKEND = #values = filesystem, +VM_DIR = +IMG_DIR = +CEPH_VM_POOL = +CEPH_IMG_POOL = From 00563c7dc2e1f7fc4cd3c9f859afb36adef9daa0 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 8 Dec 2019 13:51:40 +0100 Subject: [PATCH 061/284] [filescanner] use configparser --- conf/ucloud.conf | 11 ++++++++++- ucloud/filescanner/main.py | 9 ++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/conf/ucloud.conf b/conf/ucloud.conf index e0e3c27..2a06c81 100644 --- a/conf/ucloud.conf +++ b/conf/ucloud.conf @@ -41,8 +41,17 @@ USER_PREFIX = users VM_PREFIX = vms [storage] -STORAGE_BACKEND = #values = filesystem, +#values = filesystem, ceph +STORAGE_BACKEND = + +# if STORAGE_BACKEND = filesystem VM_DIR = IMG_DIR = + +# if STORAGE_BACKEND = ceph CEPH_VM_POOL = CEPH_IMG_POOL = + +# Importing uploaded files +FILE_DIR = /var/lib/ucloud/files +FILE_PREFIX = noclue-ahmed \ No newline at end of file diff --git a/ucloud/filescanner/main.py b/ucloud/filescanner/main.py index b70cb5b..ef6fee6 100755 --- a/ucloud/filescanner/main.py +++ b/ucloud/filescanner/main.py @@ -6,7 +6,7 @@ import time from uuid import uuid4 from . import logger -from ucloud.config import env_vars, etcd_client +from ucloud.config import config, etcd_client def getxattr(file, attr): @@ -37,7 +37,7 @@ def setxattr(file, attr, value): def sha512sum(file: str): """Use sha512sum utility to compute sha512 sum of arg:file - + IF arg:file does not exists: raise FileNotFoundError exception ELSE IF sum successfully computer: @@ -69,9 +69,8 @@ except Exception as e: def main(): - BASE_DIR = env_vars.get("BASE_DIR") - - FILE_PREFIX = env_vars.get("FILE_PREFIX") + BASE_DIR = config['storage']["FILE_DIR"] + FILE_PREFIX = config['storage']["FILE_PREFIX"] # Recursively Get All Files and Folder below BASE_DIR files = glob.glob("{}/**".format(BASE_DIR), recursive=True) From b2de2772447d8c8793fa0b3455503457f45b86af Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 8 Dec 2019 13:51:52 +0100 Subject: [PATCH 062/284] [conf] add values for filescanner --- ucloud/config.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/ucloud/config.py b/ucloud/config.py index b8b4dcf..834d51e 100644 --- a/ucloud/config.py +++ b/ucloud/config.py @@ -5,18 +5,14 @@ from ucloud.common.request import RequestPool from ucloud.common.vm import VmPool from ucloud.common.storage_handlers import FileSystemBasedImageStorageHandler, CEPHBasedImageStorageHandler -from decouple import Config, RepositoryEnv, RepositoryEmpty - # Replacing decouple inline import configparser import os import os.path import logging - log = logging.getLogger("ucloud.config") - conf_name = "ucloud.conf" try: @@ -34,14 +30,6 @@ try: except FileNotFoundError: log.warn("Configuration file not found - using defaults") - -# Try importing config, but don't fail if it does not exist -# try: -# env_vars = Config(RepositoryEnv('/etc/ucloud/ucloud.conf')) -# except FileNotFoundError: -# env_vars = Config(RepositoryEmpty()) - - etcd_wrapper_args = () etcd_wrapper_kwargs = { 'host': config['etcd']['ETCD_URL'], From fee1cfd4ff8376a0456ac64e94e8fb47d408e17e Mon Sep 17 00:00:00 2001 From: llnu Date: Sun, 8 Dec 2019 14:01:41 +0100 Subject: [PATCH 063/284] [conf] added ssh dictionary --- conf/ucloud.conf | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/conf/ucloud.conf b/conf/ucloud.conf index e0e3c27..e6b92cb 100644 --- a/conf/ucloud.conf +++ b/conf/ucloud.conf @@ -41,8 +41,21 @@ USER_PREFIX = users VM_PREFIX = vms [storage] -STORAGE_BACKEND = #values = filesystem, +#values = filesystem, ceph +STORAGE_BACKEND = + +# if STORAGE_BACKEND = filesystem VM_DIR = IMG_DIR = + +# if STORAGE_BACKEND = ceph CEPH_VM_POOL = CEPH_IMG_POOL = + +# Importing uploaded files +FILE_DIR = /var/lib/ucloud/files +FILE_PREFIX = noclue-ahmed + +[ssh] +SSH_USERNAME = +SSH_PRIVATEKEY = From 1e70d0183d1ba4308409ddd34bcb3eb575a55377 Mon Sep 17 00:00:00 2001 From: llnu Date: Sun, 8 Dec 2019 14:05:38 +0100 Subject: [PATCH 064/284] [conf] added ssh dictionary --- conf/ucloud.conf | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/conf/ucloud.conf b/conf/ucloud.conf index 2a06c81..574874e 100644 --- a/conf/ucloud.conf +++ b/conf/ucloud.conf @@ -54,4 +54,8 @@ CEPH_IMG_POOL = # Importing uploaded files FILE_DIR = /var/lib/ucloud/files -FILE_PREFIX = noclue-ahmed \ No newline at end of file +FILE_PREFIX = noclue-ahmed + +[ssh] +SSH_USERNAME = +SSH_PRIVATEKEY = From c6fe2cb1c4274136d1d36f60b5aa885a15dd7e88 Mon Sep 17 00:00:00 2001 From: llnu Date: Sun, 8 Dec 2019 14:06:15 +0100 Subject: [PATCH 065/284] [host] virtualmachine.py refactored from env_vars to config --- ucloud/host/virtualmachine.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ucloud/host/virtualmachine.py b/ucloud/host/virtualmachine.py index 7524083..33e4433 100755 --- a/ucloud/host/virtualmachine.py +++ b/ucloud/host/virtualmachine.py @@ -22,7 +22,7 @@ from ucloud.common.helpers import get_ipv6_address from ucloud.common.request import RequestEntry, RequestType from ucloud.common.vm import VMEntry, VMStatus from ucloud.config import (etcd_client, request_pool, - running_vms, vm_pool, env_vars, + running_vms, vm_pool, config, image_storage_handler) from . import qmp from ucloud.host import logger @@ -46,7 +46,7 @@ def delete_network_interface(iface): def resolve_network(network_name, network_owner): - network = etcd_client.get(join_path(env_vars.get("NETWORK_PREFIX"), + network = etcd_client.get(join_path(config['etcd']["NETWORK_PREFIX"], network_owner, network_name), value_in_json=True) @@ -179,7 +179,7 @@ def get_start_command_args(vm_entry, vnc_sock_filename: str, migration=False, mi for network_mac_and_tap in vm_networks: network_name, mac, tap = network_mac_and_tap - _key = os.path.join(env_vars.get('NETWORK_PREFIX'), vm_entry.owner, network_name) + _key = os.path.join(config['etcd']['NETWORK_PREFIX'], vm_entry.owner, network_name) network = etcd_client.get(_key, value_in_json=True) network_type = network.value["type"] network_id = str(network.value["id"]) @@ -187,7 +187,7 @@ def get_start_command_args(vm_entry, vnc_sock_filename: str, migration=False, mi if network_type == "vxlan": tap = create_vxlan_br_tap(_id=network_id, - _dev=env_vars.get("VXLAN_PHY_DEV"), + _dev=config['etcd']["VXLAN_PHY_DEV"], tap_id=tap, ip=network_ipv6) update_radvd_conf(etcd_client) @@ -303,13 +303,13 @@ def transfer(request_event): _host, _port = request_event.parameters["host"], request_event.parameters["port"] _uuid = request_event.uuid _destination = request_event.destination_host_key - vm = get_vm(running_vms, join_path(env_vars.get('VM_PREFIX'), _uuid)) + vm = get_vm(running_vms, join_path(config['etcd']['VM_PREFIX'], _uuid)) if vm: tunnel = sshtunnel.SSHTunnelForwarder( _host, - ssh_username=env_vars.get("ssh_username"), - ssh_pkey=env_vars.get("ssh_pkey"), + ssh_username=config['ssh']["ssh_username"], + ssh_pkey=config['ssh']["SSH_PRIVATEKEY"], remote_bind_address=("127.0.0.1", _port), ssh_proxy_enabled=True, ssh_proxy=(_host, 22) @@ -373,7 +373,7 @@ def launch_vm(vm_entry, migration=False, migration_port=None, destination_host_k parameters={"host": get_ipv6_address(), "port": migration_port}, uuid=vm_entry.uuid, destination_host_key=destination_host_key, - request_prefix=env_vars.get("REQUEST_PREFIX") + request_prefix=config['etcd']["REQUEST_PREFIX"] ) request_pool.put(r) else: From 9ec9083c57f7d0005ce8cc513625b7798b3020c4 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 8 Dec 2019 14:08:40 +0100 Subject: [PATCH 066/284] conf update Signed-off-by: Nico Schottelius --- conf/ucloud.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/conf/ucloud.conf b/conf/ucloud.conf index 2a06c81..cebe58e 100644 --- a/conf/ucloud.conf +++ b/conf/ucloud.conf @@ -30,9 +30,9 @@ CERT_CERT = changeme CERT_KEY = changeme -FILE_PREFIX = file/ +FILE_PREFIX = files HOST_PREFIx = hosts -IMAGE_PREFIX = image/ +IMAGE_PREFIX = images IMAGE_STORE_PREFIX = imagestore/ NETWORK_PREFIX = networks From 2a1e80dbc57cd83a64fecc1988b4f6786d14d4c4 Mon Sep 17 00:00:00 2001 From: llnu Date: Sun, 8 Dec 2019 14:11:19 +0100 Subject: [PATCH 067/284] [imagescanner] main.py refactored from env_vars to config --- ucloud/imagescanner/main.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ucloud/imagescanner/main.py b/ucloud/imagescanner/main.py index 6ff01f8..df4dfad 100755 --- a/ucloud/imagescanner/main.py +++ b/ucloud/imagescanner/main.py @@ -3,7 +3,7 @@ import os import subprocess from os.path import join as join_path -from ucloud.config import etcd_client, env_vars, image_storage_handler +from ucloud.config import etcd_client, config, image_storage_handler from ucloud.imagescanner import logger @@ -20,9 +20,9 @@ def qemu_img_type(path): def check(): """ check whether settings are sane, refuse to start if they aren't """ - if env_vars.get('STORAGE_BACKEND') == 'filesystem' and not isdir(env_vars.get('IMAGE_DIR')): + if config['etcd']['STORAGE_BACKEND'] == 'filesystem' and not isdir(config['etcd']['IMAGE_DIR']): print("You have set STORAGE_BACKEND to filesystem, but " - "{} does not exist. Refusing to start".format(env_vars.get('IMAGE_DIR'))) + "{} does not exist. Refusing to start".format(config['etcd']['IMAGE_DIR'])) sys.exit(1) try: @@ -34,7 +34,7 @@ def check(): def main(): # We want to get images entries that requests images to be created - images = etcd_client.get_prefix(env_vars.get('IMAGE_PREFIX'), value_in_json=True) + images = etcd_client.get_prefix(config['etcd']['IMAGE_PREFIX'], value_in_json=True) images_to_be_created = list(filter(lambda im: im.value['status'] == 'TO_BE_CREATED', images)) for image in images_to_be_created: @@ -43,9 +43,9 @@ def main(): image_owner = image.value['owner'] image_filename = image.value['filename'] image_store_name = image.value['store_name'] - image_full_path = join_path(env_vars.get('BASE_DIR'), image_owner, image_filename) + image_full_path = join_path(config['etcd']['BASE_DIR'], image_owner, image_filename) - image_stores = etcd_client.get_prefix(env_vars.get('IMAGE_STORE_PREFIX'), value_in_json=True) + image_stores = etcd_client.get_prefix(config['etcd']['IMAGE_STORE_PREFIX'], value_in_json=True) user_image_store = next(filter( lambda s, store_name=image_store_name: s.value["name"] == store_name, image_stores From bfbf08c7cddf54bd49ac601fd154e60b96623307 Mon Sep 17 00:00:00 2001 From: llnu Date: Sun, 8 Dec 2019 14:11:44 +0100 Subject: [PATCH 068/284] [conf] added unkown values --- conf/ucloud.conf | 5 ++ ucloud/host/main.py.old | 152 +++++++++++++++++++++++++++++++++++ ucloud/scheduler/main.py.old | 93 +++++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100755 ucloud/host/main.py.old create mode 100755 ucloud/scheduler/main.py.old diff --git a/conf/ucloud.conf b/conf/ucloud.conf index 574874e..2c3178a 100644 --- a/conf/ucloud.conf +++ b/conf/ucloud.conf @@ -59,3 +59,8 @@ FILE_PREFIX = noclue-ahmed [ssh] SSH_USERNAME = SSH_PRIVATEKEY = + +# unkown vars: +IMAGE_DIR = +BASE_DIR = +IMAGE_STORE_PREFIX = diff --git a/ucloud/host/main.py.old b/ucloud/host/main.py.old new file mode 100755 index 0000000..ae4c069 --- /dev/null +++ b/ucloud/host/main.py.old @@ -0,0 +1,152 @@ +import argparse +import multiprocessing as mp +import time + +from etcd3_wrapper import Etcd3Wrapper + +from ucloud.common.request import RequestEntry, RequestType +from ucloud.config import (vm_pool, request_pool, + etcd_client, running_vms, + etcd_wrapper_args, etcd_wrapper_kwargs, + HostPool, config) + +from .helper import find_free_port +from . import virtualmachine +from ucloud.host import logger + + +def update_heartbeat(hostname): + """Update Last HeartBeat Time for :param hostname: in etcd""" + client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) + host_pool = HostPool(client, env_vars.get('HOST_PREFIX')) + this_host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) + + while True: + this_host.update_heartbeat() + host_pool.put(this_host) + time.sleep(10) + + +def maintenance(host): + # To capture vm running according to running_vms list + + # This is to capture successful migration of a VM. + # Suppose, this host is running "vm1" and user initiated + # request to migrate this "vm1" to some other host. On, + # successful migration the destination host would set + # the vm hostname to itself. Thus, we are checking + # whether this host vm is successfully migrated. If yes + # then we shutdown "vm1" on this host. + + to_be_removed = [] + for running_vm in running_vms: + with vm_pool.get_put(running_vm.key) as vm_entry: + if vm_entry.hostname != host.key and not vm_entry.in_migration: + running_vm.handle.shutdown() + logger.info("VM migration not completed successfully.") + to_be_removed.append(running_vm) + + for r in to_be_removed: + running_vms.remove(r) + + # To check vm running according to etcd entries + alleged_running_vms = vm_pool.by_status("RUNNING", vm_pool.by_host(host.key)) + + for vm_entry in alleged_running_vms: + _vm = virtualmachine.get_vm(running_vms, vm_entry.key) + # Whether, the allegedly running vm is in our + # running_vms list or not if it is said to be + # running on this host but it is not then we + # need to shut it down + + # This is to capture poweroff/shutdown of a VM + # initiated by user inside VM. OR crash of VM by some + # user running process + if (_vm and not _vm.handle.is_running()) or not _vm: + logger.debug("_vm = %s, is_running() = %s" % (_vm, _vm.handle.is_running())) + vm_entry.add_log("""{} is not running but is said to be running. + So, shutting it down and declare it killed""".format(vm_entry.key)) + vm_entry.declare_killed() + vm_pool.put(vm_entry) + if _vm: + running_vms.remove(_vm) + +def check(): + if env_vars.get('STORAGE_BACKEND') == 'filesystem' and not isdir(env_vars.get('VM_DIR')): + print("You have set STORAGE_BACKEND to filesystem. So, the vm directory mentioned" + " in .env file must exists. But, it don't.") + sys.exit(1) + + + +def main(hostname): + check() + + heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(hostname,)) + + host_pool = HostPool(etcd_client, env_vars.get('HOST_PREFIX')) + host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) + assert host is not None, "No such host with name = {}".format(hostname) + + try: + heartbeat_updating_process.start() + except Exception as e: + logger.info("No Need To Go Further. Our heartbeat updating mechanism is not working") + logger.exception(e) + exit(-1) + + logger.info("%s Session Started %s", '*' * 5, '*' * 5) + + # It is seen that under heavy load, timeout event doesn't come + # in a predictive manner (which is intentional because we give + # higher priority to customer's requests) which delays heart + # beat update which in turn misunderstood by scheduler that the + # host is dead when it is actually alive. So, to ensure that we + # update the heart beat in a predictive manner we start Heart + # beat updating mechanism in separated thread + + for events_iterator in [ + etcd_client.get_prefix(env_vars.get('REQUEST_PREFIX'), value_in_json=True), + etcd_client.watch_prefix(env_vars.get('REQUEST_PREFIX'), timeout=10, value_in_json=True), + ]: + for request_event in events_iterator: + request_event = RequestEntry(request_event) + + if request_event.type == "TIMEOUT": + maintenance(host) + continue + + # If the event is directed toward me OR I am destination of a InitVMMigration + if request_event.hostname == host.key or request_event.destination == host.key: + logger.debug("VM Request: %s", request_event) + + request_pool.client.client.delete(request_event.key) + vm_entry = vm_pool.get(request_event.uuid) + + if vm_entry: + if request_event.type == RequestType.StartVM: + virtualmachine.start(vm_entry) + + elif request_event.type == RequestType.StopVM: + virtualmachine.stop(vm_entry) + + elif request_event.type == RequestType.DeleteVM: + virtualmachine.delete(vm_entry) + + elif request_event.type == RequestType.InitVMMigration: + virtualmachine.start(vm_entry, host.key, find_free_port()) + + elif request_event.type == RequestType.TransferVM: + virtualmachine.transfer(request_event) + else: + logger.info("VM Entry missing") + + logger.info("Running VMs %s", running_vms) + + +if __name__ == "__main__": + argparser = argparse.ArgumentParser() + argparser.add_argument("hostname", help="Name of this host. e.g /v1/host/1") + args = argparser.parse_args() + mp.set_start_method('spawn') + main(args.hostname) diff --git a/ucloud/scheduler/main.py.old b/ucloud/scheduler/main.py.old new file mode 100755 index 0000000..e2c975a --- /dev/null +++ b/ucloud/scheduler/main.py.old @@ -0,0 +1,93 @@ +# TODO +# 1. send an email to an email address defined by env['admin-email'] +# if resources are finished +# 2. Introduce a status endpoint of the scheduler - +# maybe expose a prometheus compatible output + +from ucloud.common.request import RequestEntry, RequestType +from ucloud.config import etcd_client +from ucloud.config import host_pool, request_pool, vm_pool, env_vars +from .helper import (get_suitable_host, dead_host_mitigation, dead_host_detection, + assign_host, NoSuitableHostFound) +from . import logger + + +def main(): + logger.info("%s SESSION STARTED %s", '*' * 5, '*' * 5) + + pending_vms = [] + + for request_iterator in [ + etcd_client.get_prefix(env_vars.get('REQUEST_PREFIX'), value_in_json=True), + etcd_client.watch_prefix(env_vars.get('REQUEST_PREFIX'), timeout=5, value_in_json=True), + ]: + for request_event in request_iterator: + request_entry = RequestEntry(request_event) + # Never Run time critical mechanism inside timeout + # mechanism because timeout mechanism only comes + # when no other event is happening. It means under + # heavy load there would not be a timeout event. + if request_entry.type == "TIMEOUT": + + # Detect hosts that are dead and set their status + # to "DEAD", and their VMs' status to "KILLED" + dead_hosts = dead_host_detection() + if dead_hosts: + logger.debug("Dead hosts: %s", dead_hosts) + dead_host_mitigation(dead_hosts) + + # If there are VMs that weren't assigned a host + # because there wasn't a host available which + # meets requirement of that VM then we would + # create a new ScheduleVM request for that VM + # on our behalf. + while pending_vms: + pending_vm_entry = pending_vms.pop() + r = RequestEntry.from_scratch(type="ScheduleVM", + uuid=pending_vm_entry.uuid, + hostname=pending_vm_entry.hostname, + request_prefix=env_vars.get("REQUEST_PREFIX")) + request_pool.put(r) + + elif request_entry.type == RequestType.ScheduleVM: + logger.debug("%s, %s", request_entry.key, request_entry.value) + + vm_entry = vm_pool.get(request_entry.uuid) + if vm_entry is None: + logger.info("Trying to act on {} but it is deleted".format(request_entry.uuid)) + continue + etcd_client.client.delete(request_entry.key) # consume Request + + # If the Request is about a VM which is labelled as "migration" + # and has a destination + if hasattr(request_entry, "migration") and request_entry.migration \ + and hasattr(request_entry, "destination") and request_entry.destination: + try: + get_suitable_host(vm_specs=vm_entry.specs, + hosts=[host_pool.get(request_entry.destination)]) + except NoSuitableHostFound: + logger.info("Requested destination host doesn't have enough capacity" + "to hold %s" % vm_entry.uuid) + else: + r = RequestEntry.from_scratch(type=RequestType.InitVMMigration, + uuid=request_entry.uuid, + destination=request_entry.destination, + request_prefix=env_vars.get("REQUEST_PREFIX")) + request_pool.put(r) + + # If the Request is about a VM that just want to get started/created + else: + # assign_host only returns None when we couldn't be able to assign + # a host to a VM because of resource constraints + try: + assign_host(vm_entry) + except NoSuitableHostFound: + vm_entry.add_log("Can't schedule VM. No Resource Left.") + vm_pool.put(vm_entry) + + pending_vms.append(vm_entry) + logger.info("No Resource Left. Emailing admin....") + + +if __name__ == "__main__": + main() From 8b90755015ef098935cd2b4e0eb878402ea0fee6 Mon Sep 17 00:00:00 2001 From: llnu Date: Sun, 8 Dec 2019 14:14:32 +0100 Subject: [PATCH 069/284] removed unwanted file --- ucloud/host/main.py.old | 152 ---------------------------------------- 1 file changed, 152 deletions(-) delete mode 100755 ucloud/host/main.py.old diff --git a/ucloud/host/main.py.old b/ucloud/host/main.py.old deleted file mode 100755 index ae4c069..0000000 --- a/ucloud/host/main.py.old +++ /dev/null @@ -1,152 +0,0 @@ -import argparse -import multiprocessing as mp -import time - -from etcd3_wrapper import Etcd3Wrapper - -from ucloud.common.request import RequestEntry, RequestType -from ucloud.config import (vm_pool, request_pool, - etcd_client, running_vms, - etcd_wrapper_args, etcd_wrapper_kwargs, - HostPool, config) - -from .helper import find_free_port -from . import virtualmachine -from ucloud.host import logger - - -def update_heartbeat(hostname): - """Update Last HeartBeat Time for :param hostname: in etcd""" - client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) - host_pool = HostPool(client, env_vars.get('HOST_PREFIX')) - this_host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) - - while True: - this_host.update_heartbeat() - host_pool.put(this_host) - time.sleep(10) - - -def maintenance(host): - # To capture vm running according to running_vms list - - # This is to capture successful migration of a VM. - # Suppose, this host is running "vm1" and user initiated - # request to migrate this "vm1" to some other host. On, - # successful migration the destination host would set - # the vm hostname to itself. Thus, we are checking - # whether this host vm is successfully migrated. If yes - # then we shutdown "vm1" on this host. - - to_be_removed = [] - for running_vm in running_vms: - with vm_pool.get_put(running_vm.key) as vm_entry: - if vm_entry.hostname != host.key and not vm_entry.in_migration: - running_vm.handle.shutdown() - logger.info("VM migration not completed successfully.") - to_be_removed.append(running_vm) - - for r in to_be_removed: - running_vms.remove(r) - - # To check vm running according to etcd entries - alleged_running_vms = vm_pool.by_status("RUNNING", vm_pool.by_host(host.key)) - - for vm_entry in alleged_running_vms: - _vm = virtualmachine.get_vm(running_vms, vm_entry.key) - # Whether, the allegedly running vm is in our - # running_vms list or not if it is said to be - # running on this host but it is not then we - # need to shut it down - - # This is to capture poweroff/shutdown of a VM - # initiated by user inside VM. OR crash of VM by some - # user running process - if (_vm and not _vm.handle.is_running()) or not _vm: - logger.debug("_vm = %s, is_running() = %s" % (_vm, _vm.handle.is_running())) - vm_entry.add_log("""{} is not running but is said to be running. - So, shutting it down and declare it killed""".format(vm_entry.key)) - vm_entry.declare_killed() - vm_pool.put(vm_entry) - if _vm: - running_vms.remove(_vm) - -def check(): - if env_vars.get('STORAGE_BACKEND') == 'filesystem' and not isdir(env_vars.get('VM_DIR')): - print("You have set STORAGE_BACKEND to filesystem. So, the vm directory mentioned" - " in .env file must exists. But, it don't.") - sys.exit(1) - - - -def main(hostname): - check() - - heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(hostname,)) - - host_pool = HostPool(etcd_client, env_vars.get('HOST_PREFIX')) - host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) - assert host is not None, "No such host with name = {}".format(hostname) - - try: - heartbeat_updating_process.start() - except Exception as e: - logger.info("No Need To Go Further. Our heartbeat updating mechanism is not working") - logger.exception(e) - exit(-1) - - logger.info("%s Session Started %s", '*' * 5, '*' * 5) - - # It is seen that under heavy load, timeout event doesn't come - # in a predictive manner (which is intentional because we give - # higher priority to customer's requests) which delays heart - # beat update which in turn misunderstood by scheduler that the - # host is dead when it is actually alive. So, to ensure that we - # update the heart beat in a predictive manner we start Heart - # beat updating mechanism in separated thread - - for events_iterator in [ - etcd_client.get_prefix(env_vars.get('REQUEST_PREFIX'), value_in_json=True), - etcd_client.watch_prefix(env_vars.get('REQUEST_PREFIX'), timeout=10, value_in_json=True), - ]: - for request_event in events_iterator: - request_event = RequestEntry(request_event) - - if request_event.type == "TIMEOUT": - maintenance(host) - continue - - # If the event is directed toward me OR I am destination of a InitVMMigration - if request_event.hostname == host.key or request_event.destination == host.key: - logger.debug("VM Request: %s", request_event) - - request_pool.client.client.delete(request_event.key) - vm_entry = vm_pool.get(request_event.uuid) - - if vm_entry: - if request_event.type == RequestType.StartVM: - virtualmachine.start(vm_entry) - - elif request_event.type == RequestType.StopVM: - virtualmachine.stop(vm_entry) - - elif request_event.type == RequestType.DeleteVM: - virtualmachine.delete(vm_entry) - - elif request_event.type == RequestType.InitVMMigration: - virtualmachine.start(vm_entry, host.key, find_free_port()) - - elif request_event.type == RequestType.TransferVM: - virtualmachine.transfer(request_event) - else: - logger.info("VM Entry missing") - - logger.info("Running VMs %s", running_vms) - - -if __name__ == "__main__": - argparser = argparse.ArgumentParser() - argparser.add_argument("hostname", help="Name of this host. e.g /v1/host/1") - args = argparser.parse_args() - mp.set_start_method('spawn') - main(args.hostname) From 5b44034602faa719939f268d0163461bdb2d0943 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 8 Dec 2019 14:15:36 +0100 Subject: [PATCH 070/284] cleanup --- bin/ucloud-run-reinstall | 0 scripts/ucloud | 11 +++-------- ucloud/api/main.py | 22 ++++++++++------------ ucloud/config.py | 3 +-- 4 files changed, 14 insertions(+), 22 deletions(-) mode change 100644 => 100755 bin/ucloud-run-reinstall diff --git a/bin/ucloud-run-reinstall b/bin/ucloud-run-reinstall old mode 100644 new mode 100755 diff --git a/scripts/ucloud b/scripts/ucloud index b8ef32d..0780fb4 100755 --- a/scripts/ucloud +++ b/scripts/ucloud @@ -18,21 +18,16 @@ if __name__ == "__main__": arg_parser.add_argument('component_args', nargs='*') args = arg_parser.parse_args() + if args.conf_dir: + os.environ['UCLOUD_CONF_DIR'] = args.conf_dir + try: name = args.component mod = importlib.import_module("ucloud.{}.main".format(name)) main = getattr(mod, "main") - if args.conf_dir: - print("setting conf") - os.environ['UCLOUD_CONF_DIR'] = args.conf_dir - main() - # except decouple.UndefinedValueError as e: - # print(e) - # sys.exit(1) - except Exception as e: logging.exception(e) print(e) diff --git a/ucloud/api/main.py b/ucloud/api/main.py index d3ddd5d..bbda7e9 100644 --- a/ucloud/api/main.py +++ b/ucloud/api/main.py @@ -28,7 +28,7 @@ class CreateVM(Resource): validator = schemas.CreateVMSchema(data) if validator.is_valid(): vm_uuid = uuid4().hex - vm_key = join_path(config['api']["VM_PREFIX"), vm_uuid) + vm_key = join_path(config['etcd']["VM_PREFIX"], vm_uuid) specs = { "cpu": validator.specs["cpu"], "ram": validator.specs["ram"], @@ -56,7 +56,7 @@ class CreateVM(Resource): # Create ScheduleVM Request r = RequestEntry.from_scratch( type=RequestType.ScheduleVM, uuid=vm_uuid, - request_prefix=config['api']["REQUEST_PREFIX") + request_prefix=config['etcd']["REQUEST_PREFIX"] ) request_pool.put(r) @@ -71,7 +71,7 @@ class VmStatus(Resource): validator = schemas.VMStatusSchema(data) if validator.is_valid(): vm = vm_pool.get( - join_path(config['api']["VM_PREFIX"), data["uuid"]) + join_path(config['etcd']["VM_PREFIX"], data["uuid"]) ) vm_value = vm.value.copy() vm_value["ip"] = [] @@ -79,7 +79,7 @@ class VmStatus(Resource): network_name, mac, tap = network_mac_and_tap network = etcd_client.get( join_path( - config['api']["NETWORK_PREFIX"), + config['etcd']["NETWORK_PREFIX"], data["name"], network_name, ), @@ -100,7 +100,7 @@ class CreateImage(Resource): validator = schemas.CreateImageSchema(data) if validator.is_valid(): file_entry = etcd_client.get( - join_path(config['api']["FILE_PREFIX"), data["uuid"]) + join_path(config['etcd']["FILE_PREFIX"], data["uuid"]) ) file_entry_value = json.loads(file_entry.value) @@ -113,7 +113,7 @@ class CreateImage(Resource): "visibility": "public", } etcd_client.put( - join_path(config['etcd']["IMAGE_PREFIX"), data["uuid"]), + join_path(config['etcd']["IMAGE_PREFIX"], data["uuid"]), json.dumps(image_entry_json), ) @@ -125,7 +125,7 @@ class ListPublicImages(Resource): @staticmethod def get(): images = etcd_client.get_prefix( - config['etcd']["IMAGE_PREFIX"), value_in_json=True + config['etcd']["IMAGE_PREFIX"], value_in_json=True ) r = { "images": [] @@ -148,7 +148,7 @@ class VMAction(Resource): if validator.is_valid(): vm_entry = vm_pool.get( - join_path(config['etcd']["VM_PREFIX"), data["uuid"]) + join_path(config['etcd']["VM_PREFIX"], data["uuid"]) ) action = data["action"] @@ -326,7 +326,7 @@ class GetSSHKeys(Resource): # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - config['etcd']['USER_PREFIX'), + config['etcd']['USER_PREFIX'], data["realm"], data["name"], "key", @@ -430,9 +430,7 @@ class CreateNetwork(Resource): prefix = nb_prefix.available_prefixes.create( data={ - "prefix_length": config['network'][ - "PREFIX_LENGTH"] - ), + "prefix_length": config['network']["PREFIX_LENGTH"], "description": '{}\'s network "{}"'.format( data["name"], data["network_name"] ), diff --git a/ucloud/config.py b/ucloud/config.py index 834d51e..4a067cb 100644 --- a/ucloud/config.py +++ b/ucloud/config.py @@ -25,8 +25,7 @@ config_file = os.path.join(conf_dir, conf_name) config = configparser.ConfigParser() try: - with open(config_file, "r") as conf_fd: - conf.read(conf_fd) + config.read(config_file) except FileNotFoundError: log.warn("Configuration file not found - using defaults") From 0283894ba2eafd4dbb8eace1188d979334695069 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 8 Dec 2019 14:16:22 +0100 Subject: [PATCH 071/284] remove non-unknown vars --- conf/ucloud.conf | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/conf/ucloud.conf b/conf/ucloud.conf index cf7ff7e..ae33cf5 100644 --- a/conf/ucloud.conf +++ b/conf/ucloud.conf @@ -57,10 +57,5 @@ FILE_DIR = /var/lib/ucloud/files FILE_PREFIX = noclue-ahmed [ssh] -SSH_USERNAME = -SSH_PRIVATEKEY = - -# unkown vars: -IMAGE_DIR = -BASE_DIR = -IMAGE_STORE_PREFIX = +SSH_USERNAME = +SSH_PRIVATEKEY = \ No newline at end of file From 8e159c8be134393d08c85b7b0594269877721cc7 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 8 Dec 2019 14:20:26 +0100 Subject: [PATCH 072/284] add hacking template --- ucloud/docs/source/hacking.rst | 16 ++++++++++++++++ ucloud/docs/source/index.rst | 1 + 2 files changed, 17 insertions(+) create mode 100644 ucloud/docs/source/hacking.rst diff --git a/ucloud/docs/source/hacking.rst b/ucloud/docs/source/hacking.rst new file mode 100644 index 0000000..976970b --- /dev/null +++ b/ucloud/docs/source/hacking.rst @@ -0,0 +1,16 @@ +Hacking +======= +How to hack on the code. + +[ to be done by Balazs: + +* make nice +* indent with shell script mode + +] + +* git clone the repo +* cd to the repo +* Setup your venv: python -m venv venv +* Run ./bin/ucloud-run-reinstall - it should print you an error + message on how to use ucloud diff --git a/ucloud/docs/source/index.rst b/ucloud/docs/source/index.rst index 879ac32..b31cff3 100644 --- a/ucloud/docs/source/index.rst +++ b/ucloud/docs/source/index.rst @@ -16,6 +16,7 @@ Welcome to ucloud's documentation! admin-guide user-guide/how-to-create-an-os-image-for-ucloud troubleshooting + hacking Indices and tables From e79f1b4de9a82876bdb8736f5828a01b3ce8339e Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 8 Dec 2019 14:22:56 +0100 Subject: [PATCH 073/284] ++notes --- ucloud/docs/source/hacking.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/ucloud/docs/source/hacking.rst b/ucloud/docs/source/hacking.rst index 976970b..2df42a7 100644 --- a/ucloud/docs/source/hacking.rst +++ b/ucloud/docs/source/hacking.rst @@ -12,5 +12,6 @@ How to hack on the code. * git clone the repo * cd to the repo * Setup your venv: python -m venv venv +* . ./venv/bin/activate # you need the leading dot for sourcing! * Run ./bin/ucloud-run-reinstall - it should print you an error message on how to use ucloud From 8afd524c55329a32b4d75b352153ce04d1addf4c Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 8 Dec 2019 14:55:26 +0100 Subject: [PATCH 074/284] [config] inline etcd3 to get things moving faster - cleanup later --- ucloud/config.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/ucloud/config.py b/ucloud/config.py index 4a067cb..5662e64 100644 --- a/ucloud/config.py +++ b/ucloud/config.py @@ -1,5 +1,3 @@ -from etcd3_wrapper import Etcd3Wrapper - from ucloud.common.host import HostPool from ucloud.common.request import RequestPool from ucloud.common.vm import VmPool @@ -29,6 +27,84 @@ try: except FileNotFoundError: log.warn("Configuration file not found - using defaults") + +################################################################################ +# ETCD3 support +import etcd3 +import json +import queue +import copy +from collections import namedtuple + +PseudoEtcdMeta = namedtuple("PseudoEtcdMeta", ["key"]) + +class EtcdEntry: + # key: str + # value: str + + def __init__(self, meta, value, value_in_json=False): + self.key = meta.key.decode("utf-8") + self.value = value.decode("utf-8") + + if value_in_json: + self.value = json.loads(self.value) + +class Etcd3Wrapper: + def __init__(self, *args, **kwargs): + self.client = etcd3.client(*args, **kwargs) + + def get(self, *args, value_in_json=False, **kwargs): + _value, _key = self.client.get(*args, **kwargs) + if _key is None or _value is None: + return None + return EtcdEntry(_key, _value, value_in_json=value_in_json) + + def put(self, *args, value_in_json=False, **kwargs): + _key, _value = args + if value_in_json: + _value = json.dumps(_value) + + if not isinstance(_key, str): + _key = _key.decode("utf-8") + + return self.client.put(_key, _value, **kwargs) + + def get_prefix(self, *args, value_in_json=False, **kwargs): + r = self.client.get_prefix(*args, **kwargs) + for entry in r: + e = EtcdEntry(*entry[::-1], value_in_json=value_in_json) + if e.value: + yield e + + def watch_prefix(self, key, timeout=0, value_in_json=False): + timeout_event = EtcdEntry(PseudoEtcdMeta(key=b"TIMEOUT"), + value=str.encode(json.dumps({"status": "TIMEOUT", + "type": "TIMEOUT"})), + value_in_json=value_in_json) + + event_queue = queue.Queue() + + def add_event_to_queue(event): + for e in event.events: + if e.value: + event_queue.put(EtcdEntry(e, e.value, value_in_json=value_in_json)) + + self.client.add_watch_prefix_callback(key, add_event_to_queue) + + while True: + try: + while True: + v = event_queue.get(timeout=timeout) + yield v + except queue.Empty: + event_queue.put(copy.deepcopy(timeout_event)) + + +class PsuedoEtcdEntry(EtcdEntry): + def __init__(self, key, value, value_in_json=False): + super().__init__(PseudoEtcdMeta(key=key.encode("utf-8")), value, value_in_json=value_in_json) + + etcd_wrapper_args = () etcd_wrapper_kwargs = { 'host': config['etcd']['ETCD_URL'], From f919719b1e2686c85c01b9f7b4fee4aec5baea5c Mon Sep 17 00:00:00 2001 From: Ahmed Bilal <49-ahmedbilal@users.noreply.code.ungleich.ch> Date: Sun, 8 Dec 2019 15:03:49 +0100 Subject: [PATCH 075/284] Update ucloud.conf --- conf/ucloud.conf | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/conf/ucloud.conf b/conf/ucloud.conf index ae33cf5..a98250a 100644 --- a/conf/ucloud.conf +++ b/conf/ucloud.conf @@ -25,15 +25,15 @@ NETBOX_TOKEN = replace me ETCD_URL = localhost ETCD_PORT = 2379 -CA_CERT = changeme -CERT_CERT = changeme -CERT_KEY = changeme +CA_CERT +CERT_CERT +CERT_KEY FILE_PREFIX = files HOST_PREFIx = hosts IMAGE_PREFIX = images -IMAGE_STORE_PREFIX = imagestore/ +IMAGE_STORE_PREFIX = imagestore NETWORK_PREFIX = networks REQUEST_PREFIX = requests @@ -54,7 +54,6 @@ CEPH_IMG_POOL = # Importing uploaded files FILE_DIR = /var/lib/ucloud/files -FILE_PREFIX = noclue-ahmed [ssh] SSH_USERNAME = From 71279a968f649f203f6b5577f495594176ca818d Mon Sep 17 00:00:00 2001 From: meow Date: Sat, 14 Dec 2019 20:23:31 +0500 Subject: [PATCH 076/284] Fix issues in naming and few other things --- conf/ucloud.conf | 75 ++++++++-------- scripts/ucloud | 14 ++- setup.py | 9 +- ucloud/api/common_fields.py | 2 +- ucloud/api/create_image_store.py | 2 +- ucloud/api/helper.py | 21 +++-- ucloud/api/main.py | 93 +++++++++++--------- ucloud/api/schemas.py | 13 +-- ucloud/common/classes.py | 2 +- ucloud/common/counters.py | 2 +- ucloud/common/etcd_wrapper.py | 74 ++++++++++++++++ ucloud/common/helpers.py | 20 +---- ucloud/common/request.py | 3 +- ucloud/config.py | 144 +++++++------------------------ ucloud/filescanner/main.py | 24 +++--- ucloud/host/main.py | 19 ++-- ucloud/host/virtualmachine.py | 14 +-- ucloud/imagescanner/main.py | 11 +-- ucloud/metadata/main.py | 3 +- ucloud/scheduler/helper.py | 2 +- ucloud/scheduler/main.py | 8 +- 21 files changed, 274 insertions(+), 281 deletions(-) create mode 100644 ucloud/common/etcd_wrapper.py diff --git a/conf/ucloud.conf b/conf/ucloud.conf index a98250a..222cc4f 100644 --- a/conf/ucloud.conf +++ b/conf/ucloud.conf @@ -1,60 +1,53 @@ -# This section contains default values for all other sections -# -[DEFAULT] - -AUTH_NAME = "replace me" -AUTH_SEED = "replace me" -AUTH_REALM = "replace me" - -NETWORK_PREFIX = moo - -OTP_VERIFY_ENDPOINT = verify/ - -[api] -NETWORK_PREFIX = foo +[otp] +server = https://otp.ungleich.ch/ungleichotp/ +verify_endpoint = verify/ +auth_name = replace_me +auth_realm = replace_me +auth_seed = replace_me [network] -PREFIX_LENGTH = 64 -PREFIX = 2001:db8::/48 +prefix_length = 64 +prefix = 2001:db8::/48 +vxlan_phy_dev = eno1 [netbox] -NETBOX_URL = https://replace-me.example.com -NETBOX_TOKEN = replace me +url = https://replace-me.example.com +token = replace_me [etcd] -ETCD_URL = localhost -ETCD_PORT = 2379 +url = localhost +port = 2379 -CA_CERT -CERT_CERT -CERT_KEY +ca_cert +cert_cert +cert_key - -FILE_PREFIX = files -HOST_PREFIx = hosts -IMAGE_PREFIX = images -IMAGE_STORE_PREFIX = imagestore - -NETWORK_PREFIX = networks -REQUEST_PREFIX = requests -USER_PREFIX = users -VM_PREFIX = vms +file_prefix = /files/ +host_prefix = /hosts/ +image_prefix = /images/ +image_store_prefix = /imagestore/ +network_prefix = /networks/ +request_prefix = /requests/ +user_prefix = /users/ +vm_prefix = /vms/ [storage] + #values = filesystem, ceph -STORAGE_BACKEND = +backend = filesystem # if STORAGE_BACKEND = filesystem -VM_DIR = -IMG_DIR = +vm_dir = /var/lib/ucloud/vms +image_dir = /var/lib/ucloud/images # if STORAGE_BACKEND = ceph -CEPH_VM_POOL = -CEPH_IMG_POOL = +ceph_vm_pool = ssd +ceph_image_pool = ssd # Importing uploaded files -FILE_DIR = /var/lib/ucloud/files +file_dir = /var/lib/ucloud/files +# For Migrating VMs over ssh/tcp [ssh] -SSH_USERNAME = -SSH_PRIVATEKEY = \ No newline at end of file +username +private_key_path \ No newline at end of file diff --git a/scripts/ucloud b/scripts/ucloud index 0780fb4..2da1094 100755 --- a/scripts/ucloud +++ b/scripts/ucloud @@ -5,11 +5,17 @@ import logging import importlib import sys import os +import multiprocessing as mp + COMMANDS = ['api', 'scheduler', 'host', 'filescanner', 'imagescanner', 'metadata'] if __name__ == "__main__": - log = logging.getLogger("ucloud") + logging.basicConfig(level=logging.DEBUG, + format='%(pathname)s:%(lineno)d -- %(levelname)-8s %(message)s', + filename='/var/log/ucloud.log', filemode='a') + + logger = logging.getLogger("ucloud") arg_parser = argparse.ArgumentParser(prog='ucloud', description='Open Source Cloud Management Software') @@ -22,12 +28,12 @@ if __name__ == "__main__": os.environ['UCLOUD_CONF_DIR'] = args.conf_dir try: + mp.set_start_method('spawn') name = args.component mod = importlib.import_module("ucloud.{}.main".format(name)) main = getattr(mod, "main") - - main() + main(*args.component_args) except Exception as e: - logging.exception(e) + logger.exception(e) print(e) diff --git a/setup.py b/setup.py index 14dffb7..a7ddbe6 100644 --- a/setup.py +++ b/setup.py @@ -8,8 +8,8 @@ try: version = ucloud.version.VERSION except: import subprocess - c = subprocess.run(["git", "describe"], capture_output=True) - version = c.stdout.decode("utf-8") + c = subprocess.check_output(['git', 'describe']) + version = c.decode("utf-8").strip() setup(name='ucloud', @@ -28,8 +28,7 @@ setup(name='ucloud', packages=find_packages(), install_requires=[ 'requests', - 'python-decouple', - 'flask', + 'Flask', 'flask-restful', 'bitmath', 'pyotp', @@ -37,8 +36,8 @@ setup(name='ucloud', 'sphinx', 'pynetbox', 'sphinx-rtd-theme', - 'etcd3_wrapper @ https://code.ungleich.ch/ungleich-public/etcd3_wrapper/repository/master/archive.tar.gz#egg=etcd3_wrapper', 'etcd3 @ https://github.com/kragniz/python-etcd3/tarball/master#egg=etcd3', ], scripts=['scripts/ucloud'], + data_files=[('/etc/ucloud/', ['conf/ucloud.conf'])], zip_safe=False) diff --git a/ucloud/api/common_fields.py b/ucloud/api/common_fields.py index 1ceb1b0..cf86283 100755 --- a/ucloud/api/common_fields.py +++ b/ucloud/api/common_fields.py @@ -47,6 +47,6 @@ class VmUUIDField(Field): self.validation = self.vm_uuid_validation def vm_uuid_validation(self): - r = etcd_client.get(os.path.join(config['api']['VM_PREFIX'], self.uuid)) + r = etcd_client.get(os.path.join(config['etcd']['vm_prefix'], self.uuid)) if not r: self.add_error("VM with uuid {} does not exists".format(self.uuid)) diff --git a/ucloud/api/create_image_store.py b/ucloud/api/create_image_store.py index 259b9c8..9023bd6 100755 --- a/ucloud/api/create_image_store.py +++ b/ucloud/api/create_image_store.py @@ -13,4 +13,4 @@ data = { "attributes": {"list": [], "key": [], "pool": "images"}, } -etcd_client.put(os.path.join(config['api']['IMAGE_STORE_PREFIX'], uuid4().hex), json.dumps(data)) +etcd_client.put(os.path.join(config['etcd']['image_store_prefix'], uuid4().hex), json.dumps(data)) diff --git a/ucloud/api/helper.py b/ucloud/api/helper.py index 6735f05..2dfb7de 100755 --- a/ucloud/api/helper.py +++ b/ucloud/api/helper.py @@ -2,7 +2,7 @@ import binascii import ipaddress import random import subprocess as sp - +import logging import requests from pyotp import TOTP @@ -10,23 +10,28 @@ from pyotp import TOTP from ucloud.config import vm_pool, config +logger = logging.getLogger("ucloud.api.helper") + def check_otp(name, realm, token): try: data = { - "auth_name": config['api']["AUTH_NAME"], - "auth_token": TOTP(config['api']["AUTH_SEED"]).now(), - "auth_realm": config['api']["AUTH_REALM"], + "auth_name": config['otp']['auth_name'], + "auth_token": TOTP(config['otp']['auth_seed']).now(), + "auth_realm": config['otp']['auth_realm'], "name": name, "realm": realm, "token": token, } - except binascii.Error: + except binascii.Error as err: + logger.error( + "Cannot compute OTP for seed: {}".format(config['otp']['auth_seed']) + ) return 400 response = requests.post( "{OTP_SERVER}{OTP_VERIFY_ENDPOINT}".format( - OTP_SERVER=config['api']["OTP_SERVER"], - OTP_VERIFY_ENDPOINT=config['api']["OTP_VERIFY_ENDPOINT"] + OTP_SERVER=config['otp']['server'], + OTP_VERIFY_ENDPOINT=config['otp']['verify_endpoint'] ), json=data, ) @@ -80,7 +85,7 @@ def resolve_image_name(name, etcd_client): except Exception: raise ValueError("Image name not in correct format i.e {store_name}:{image_name}") - images = etcd_client.get_prefix(config['api']['IMAGE_PREFIX'], value_in_json=True) + images = etcd_client.get_prefix(config['etcd']['image_prefix'], value_in_json=True) # Try to find image with name == image_name and store_name == store_name try: diff --git a/ucloud/api/main.py b/ucloud/api/main.py index bbda7e9..0e202d8 100644 --- a/ucloud/api/main.py +++ b/ucloud/api/main.py @@ -10,7 +10,10 @@ from flask_restful import Resource, Api from ucloud.common import counters from ucloud.common.vm import VMStatus from ucloud.common.request import RequestEntry, RequestType -from ucloud.config import (etcd_client, request_pool, vm_pool, host_pool, config, image_storage_handler) +from ucloud.config import ( + etcd_client, request_pool, vm_pool, + host_pool, config, image_storage_handler +) from . import schemas from .helper import generate_mac, mac2ipv6 from . import logger @@ -28,7 +31,7 @@ class CreateVM(Resource): validator = schemas.CreateVMSchema(data) if validator.is_valid(): vm_uuid = uuid4().hex - vm_key = join_path(config['etcd']["VM_PREFIX"], vm_uuid) + vm_key = join_path(config['etcd']['vm_prefix'], vm_uuid) specs = { "cpu": validator.specs["cpu"], "ram": validator.specs["ram"], @@ -56,7 +59,7 @@ class CreateVM(Resource): # Create ScheduleVM Request r = RequestEntry.from_scratch( type=RequestType.ScheduleVM, uuid=vm_uuid, - request_prefix=config['etcd']["REQUEST_PREFIX"] + request_prefix=config['etcd']['request_prefix'] ) request_pool.put(r) @@ -71,7 +74,7 @@ class VmStatus(Resource): validator = schemas.VMStatusSchema(data) if validator.is_valid(): vm = vm_pool.get( - join_path(config['etcd']["VM_PREFIX"], data["uuid"]) + join_path(config['etcd']['vm_prefix'], data["uuid"]) ) vm_value = vm.value.copy() vm_value["ip"] = [] @@ -79,7 +82,7 @@ class VmStatus(Resource): network_name, mac, tap = network_mac_and_tap network = etcd_client.get( join_path( - config['etcd']["NETWORK_PREFIX"], + config['etcd']['network_prefix'], data["name"], network_name, ), @@ -100,7 +103,7 @@ class CreateImage(Resource): validator = schemas.CreateImageSchema(data) if validator.is_valid(): file_entry = etcd_client.get( - join_path(config['etcd']["FILE_PREFIX"], data["uuid"]) + join_path(config['etcd']['file_prefix'], data["uuid"]) ) file_entry_value = json.loads(file_entry.value) @@ -113,7 +116,7 @@ class CreateImage(Resource): "visibility": "public", } etcd_client.put( - join_path(config['etcd']["IMAGE_PREFIX"], data["uuid"]), + join_path(config['etcd']['image_prefix'], data["uuid"]), json.dumps(image_entry_json), ) @@ -125,7 +128,7 @@ class ListPublicImages(Resource): @staticmethod def get(): images = etcd_client.get_prefix( - config['etcd']["IMAGE_PREFIX"], value_in_json=True + config['etcd']['image_prefix'], value_in_json=True ) r = { "images": [] @@ -148,7 +151,7 @@ class VMAction(Resource): if validator.is_valid(): vm_entry = vm_pool.get( - join_path(config['etcd']["VM_PREFIX"], data["uuid"]) + join_path(config['etcd']['vm_prefix'], data["uuid"]) ) action = data["action"] @@ -172,7 +175,7 @@ class VMAction(Resource): type="{}VM".format(action.title()), uuid=data["uuid"], hostname=vm_entry.hostname, - request_prefix=config['etcd']["REQUEST_PREFIX"] + request_prefix=config['etcd']['request_prefix'] ) request_pool.put(r) return {"message": "VM {} Queued".format(action.title())}, 200 @@ -193,10 +196,10 @@ class VMMigration(Resource): type=RequestType.ScheduleVM, uuid=vm.uuid, destination=join_path( - config['etcd']["HOST_PREFIX"], validator.destination.value + config['etcd']['host_prefix'], validator.destination.value ), migration=True, - request_prefix=config['etcd']["REQUEST_PREFIX"] + request_prefix=config['etcd']['request_prefix'] ) request_pool.put(r) return {"message": "VM Migration Initialization Queued"}, 200 @@ -212,7 +215,7 @@ class ListUserVM(Resource): if validator.is_valid(): vms = etcd_client.get_prefix( - config['etcd']["VM_PREFIX"], value_in_json=True + config['etcd']['vm_prefix'], value_in_json=True ) return_vms = [] user_vms = filter(lambda v: v.value["owner"] == data["name"], vms) @@ -246,7 +249,7 @@ class ListUserFiles(Resource): if validator.is_valid(): files = etcd_client.get_prefix( - config['etcd']["FILE_PREFIX"], value_in_json=True + config['etcd']['file_prefix'], value_in_json=True ) return_files = [] user_files = list( @@ -270,7 +273,7 @@ class CreateHost(Resource): data = request.json validator = schemas.CreateHostSchema(data) if validator.is_valid(): - host_key = join_path(config['etcd']["HOST_PREFIX"], uuid4().hex) + host_key = join_path(config['etcd']['host_prefix'], uuid4().hex) host_entry = { "specs": data["specs"], "hostname": data["hostname"], @@ -309,7 +312,7 @@ class GetSSHKeys(Resource): # {user_prefix}/{realm}/{name}/key/ etcd_key = join_path( - config['etcd']['USER_PREFIX'], + config['etcd']['user_prefix'], data["realm"], data["name"], "key", @@ -326,7 +329,7 @@ class GetSSHKeys(Resource): # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - config['etcd']['USER_PREFIX'], + config['etcd']['user_prefix'], data["realm"], data["name"], "key", @@ -355,7 +358,7 @@ class AddSSHKey(Resource): # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - config['etcd']["USER_PREFIX"], + config['etcd']['user_prefix'], data["realm"], data["name"], "key", @@ -385,7 +388,7 @@ class RemoveSSHKey(Resource): # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - config['etcd']["USER_PREFIX"], + config['etcd']['user_prefix'], data["realm"], data["name"], "key", @@ -420,31 +423,35 @@ class CreateNetwork(Resource): "type": data["type"], } if validator.user.value: - nb = pynetbox.api( - url=config['netbox']["NETBOX_URL"], - token=config['netbox']["NETBOX_TOKEN"], - ) - nb_prefix = nb.ipam.prefixes.get( - prefix=config['network']["PREFIX"] - ) - - prefix = nb_prefix.available_prefixes.create( - data={ - "prefix_length": config['network']["PREFIX_LENGTH"], - "description": '{}\'s network "{}"'.format( - data["name"], data["network_name"] - ), - "is_pool": True, - } - ) - network_entry["ipv6"] = prefix["prefix"] + try: + nb = pynetbox.api( + url=config['netbox']['url'], + token=config['netbox']['token'], + ) + nb_prefix = nb.ipam.prefixes.get( + prefix=config['network']['prefix'] + ) + prefix = nb_prefix.available_prefixes.create( + data={ + "prefix_length": int(config['network']['prefix_length']), + "description": '{}\'s network "{}"'.format( + data["name"], data["network_name"] + ), + "is_pool": True, + } + ) + except Exception: + logger.exception("Exception occur while contacting netbox") + return {"message": "Error occured while creating network."} + else: + network_entry["ipv6"] = prefix["prefix"] else: network_entry["ipv6"] = "fd00::/64" network_key = join_path( - config['network']["NETWORK_PREFIX"], - data["name"], - data["network_name"], + config['etcd']['network_prefix'], + data['name'], + data['network_name'], ) etcd_client.put(network_key, network_entry, value_in_json=True) return {"message": "Network successfully added."} @@ -460,7 +467,7 @@ class ListUserNetwork(Resource): if validator.is_valid(): prefix = join_path( - config['network']["NETWORK_PREFIX"], data["name"] + config['etcd']['network_prefix'], data["name"] ) networks = etcd_client.get_prefix(prefix, value_in_json=True) user_networks = [] @@ -496,7 +503,7 @@ api.add_resource(CreateNetwork, "/network/create") def main(): - image_stores = list(etcd_client.get_prefix(config['etcd']['IMAGE_STORE_PREFIX'], value_in_json=True)) + image_stores = list(etcd_client.get_prefix(config['etcd']['image_store_prefix'], value_in_json=True)) if len(image_stores) == 0: data = { "is_public": True, @@ -506,7 +513,7 @@ def main(): "attributes": {"list": [], "key": [], "pool": "images"}, } - etcd_client.put(join_path(config['etcd']['IMAGE_STORE_PREFIX'], uuid4().hex), json.dumps(data)) + etcd_client.put(join_path(config['etcd']['image_store_prefix'], uuid4().hex), json.dumps(data)) app.run(host="::", debug=True) diff --git a/ucloud/api/schemas.py b/ucloud/api/schemas.py index 23db184..a3e0aa8 100755 --- a/ucloud/api/schemas.py +++ b/ucloud/api/schemas.py @@ -22,7 +22,7 @@ import bitmath from ucloud.common.host import HostStatus from ucloud.common.vm import VMStatus from ucloud.config import etcd_client, config, vm_pool, host_pool -from . import helper +from . import helper, logger from .common_fields import Field, VmUUIDField from .helper import check_otp, resolve_vm_name @@ -102,14 +102,14 @@ class CreateImageSchema(BaseSchema): super().__init__(data, fields) def file_uuid_validation(self): - file_entry = etcd_client.get(os.path.join(config['etcd']['FILE_PREFIX'], self.uuid.value)) + file_entry = etcd_client.get(os.path.join(config['etcd']['file_prefix'], self.uuid.value)) if file_entry is None: self.add_error( "Image File with uuid '{}' Not Found".format(self.uuid.value) ) def image_store_name_validation(self): - image_stores = list(etcd_client.get_prefix(config['etcd']['IMAGE_STORE_PREFIX'])) + image_stores = list(etcd_client.get_prefix(config['etcd']['image_store_prefix'])) image_store = next( filter( @@ -220,6 +220,7 @@ class CreateVMSchema(OTPSchema): try: image_uuid = helper.resolve_image_name(self.image.value, etcd_client) except Exception as e: + logger.exception("Cannot resolve image name = %s", self.image.value) self.add_error(str(e)) else: self.image_uuid = image_uuid @@ -235,7 +236,7 @@ class CreateVMSchema(OTPSchema): if _network: for net in _network: - network = etcd_client.get(os.path.join(config['etcd']['NETWORK_PREFIX'], + network = etcd_client.get(os.path.join(config['etcd']['network_prefix'], self.name.value, net), value_in_json=True) if not network: @@ -400,7 +401,7 @@ class VmMigrationSchema(OTPSchema): if vm.status != VMStatus.running: self.add_error("Can't migrate non-running VM") - if vm.hostname == os.path.join(config['etcd']['HOST_PREFIX'], self.destination.value): + if vm.hostname == os.path.join(config['etcd']['host_prefix'], self.destination.value): self.add_error("Destination host couldn't be same as Source Host") @@ -442,7 +443,7 @@ class CreateNetwork(OTPSchema): super().__init__(data, fields=fields) def network_name_validation(self): - network = etcd_client.get(os.path.join(config['etcd']['NETWORK_PREFIX'], + network = etcd_client.get(os.path.join(config['etcd']['network_prefix'], self.name.value, self.network_name.value), value_in_json=True) diff --git a/ucloud/common/classes.py b/ucloud/common/classes.py index 2eae809..29dffd4 100644 --- a/ucloud/common/classes.py +++ b/ucloud/common/classes.py @@ -1,4 +1,4 @@ -from etcd3_wrapper import EtcdEntry +from .etcd_wrapper import EtcdEntry class SpecificEtcdEntryBase: diff --git a/ucloud/common/counters.py b/ucloud/common/counters.py index 066a870..2d4a8e9 100644 --- a/ucloud/common/counters.py +++ b/ucloud/common/counters.py @@ -1,4 +1,4 @@ -from etcd3_wrapper import Etcd3Wrapper +from .etcd_wrapper import Etcd3Wrapper def increment_etcd_counter(etcd_client: Etcd3Wrapper, key): diff --git a/ucloud/common/etcd_wrapper.py b/ucloud/common/etcd_wrapper.py new file mode 100644 index 0000000..a3fb83f --- /dev/null +++ b/ucloud/common/etcd_wrapper.py @@ -0,0 +1,74 @@ +import etcd3 +import json +import queue +import copy + +from collections import namedtuple + +PseudoEtcdMeta = namedtuple("PseudoEtcdMeta", ["key"]) + +class EtcdEntry: + # key: str + # value: str + + def __init__(self, meta, value, value_in_json=False): + self.key = meta.key.decode("utf-8") + self.value = value.decode("utf-8") + + if value_in_json: + self.value = json.loads(self.value) + +class Etcd3Wrapper: + def __init__(self, *args, **kwargs): + self.client = etcd3.client(*args, **kwargs) + + def get(self, *args, value_in_json=False, **kwargs): + _value, _key = self.client.get(*args, **kwargs) + if _key is None or _value is None: + return None + return EtcdEntry(_key, _value, value_in_json=value_in_json) + + def put(self, *args, value_in_json=False, **kwargs): + _key, _value = args + if value_in_json: + _value = json.dumps(_value) + + if not isinstance(_key, str): + _key = _key.decode("utf-8") + + return self.client.put(_key, _value, **kwargs) + + def get_prefix(self, *args, value_in_json=False, **kwargs): + r = self.client.get_prefix(*args, **kwargs) + for entry in r: + e = EtcdEntry(*entry[::-1], value_in_json=value_in_json) + if e.value: + yield e + + def watch_prefix(self, key, timeout=0, value_in_json=False): + timeout_event = EtcdEntry(PseudoEtcdMeta(key=b"TIMEOUT"), + value=str.encode(json.dumps({"status": "TIMEOUT", + "type": "TIMEOUT"})), + value_in_json=value_in_json) + + event_queue = queue.Queue() + + def add_event_to_queue(event): + for e in event.events: + if e.value: + event_queue.put(EtcdEntry(e, e.value, value_in_json=value_in_json)) + + self.client.add_watch_prefix_callback(key, add_event_to_queue) + + while True: + try: + while True: + v = event_queue.get(timeout=timeout) + yield v + except queue.Empty: + event_queue.put(copy.deepcopy(timeout_event)) + + +class PsuedoEtcdEntry(EtcdEntry): + def __init__(self, key, value, value_in_json=False): + super().__init__(PseudoEtcdMeta(key=key.encode("utf-8")), value, value_in_json=value_in_json) diff --git a/ucloud/common/helpers.py b/ucloud/common/helpers.py index 1bdf0b4..501aa90 100644 --- a/ucloud/common/helpers.py +++ b/ucloud/common/helpers.py @@ -6,21 +6,7 @@ import json from ipaddress import ip_address from os.path import join as join_path - - -def create_package_loggers(packages, base_path, mode="a"): - loggers = {} - for pkg in packages: - logger = logging.getLogger(pkg) - logger_handler = logging.FileHandler( - join_path(base_path, "{}.txt".format(pkg)), - mode=mode - ) - logger.setLevel(logging.DEBUG) - logger_handler.setFormatter(logging.Formatter(fmt="%(asctime)s: %(levelname)s - %(message)s", - datefmt="%d-%b-%y %H:%M:%S")) - logger.addHandler(logger_handler) - loggers[pkg] = logger +from . import logger # TODO: Should be removed as soon as migration @@ -35,7 +21,7 @@ def get_ipv4_address(): except socket.timeout: address = "127.0.0.1" except Exception as e: - logging.getLogger().exception(e) + logger.exception(e) address = "127.0.0.1" else: address = s.getsockname()[0] @@ -49,6 +35,6 @@ def get_ipv6_address(): content = json.loads(r.content.decode("utf-8")) ip = ip_address(content["ip"]).exploded except Exception as e: - logging.exception(e) + logger.exception(e) else: return ip diff --git a/ucloud/common/request.py b/ucloud/common/request.py index cadac80..1e4594d 100644 --- a/ucloud/common/request.py +++ b/ucloud/common/request.py @@ -2,8 +2,7 @@ import json from os.path import join from uuid import uuid4 -from etcd3_wrapper.etcd3_wrapper import PsuedoEtcdEntry - +from .etcd_wrapper import PsuedoEtcdEntry from .classes import SpecificEtcdEntryBase diff --git a/ucloud/config.py b/ucloud/config.py index 5662e64..3eee897 100644 --- a/ucloud/config.py +++ b/ucloud/config.py @@ -1,133 +1,53 @@ +import configparser +import os +import logging + from ucloud.common.host import HostPool from ucloud.common.request import RequestPool from ucloud.common.vm import VmPool from ucloud.common.storage_handlers import FileSystemBasedImageStorageHandler, CEPHBasedImageStorageHandler +from ucloud.common.etcd_wrapper import Etcd3Wrapper -# Replacing decouple inline -import configparser -import os -import os.path - -import logging -log = logging.getLogger("ucloud.config") - -conf_name = "ucloud.conf" - -try: - conf_dir = os.environ["UCLOUD_CONF_DIR"] -except KeyError: - conf_dir = "/etc/ucloud" +log = logging.getLogger('ucloud.config') +conf_name = 'ucloud.conf' +conf_dir = os.environ.get('UCLOUD_CONF_DIR', '/etc/ucloud') config_file = os.path.join(conf_dir, conf_name) -config = configparser.ConfigParser() +config = configparser.ConfigParser(allow_no_value=True) -try: +if os.access(config_file, os.R_OK): config.read(config_file) -except FileNotFoundError: - log.warn("Configuration file not found - using defaults") - - -################################################################################ -# ETCD3 support -import etcd3 -import json -import queue -import copy -from collections import namedtuple - -PseudoEtcdMeta = namedtuple("PseudoEtcdMeta", ["key"]) - -class EtcdEntry: - # key: str - # value: str - - def __init__(self, meta, value, value_in_json=False): - self.key = meta.key.decode("utf-8") - self.value = value.decode("utf-8") - - if value_in_json: - self.value = json.loads(self.value) - -class Etcd3Wrapper: - def __init__(self, *args, **kwargs): - self.client = etcd3.client(*args, **kwargs) - - def get(self, *args, value_in_json=False, **kwargs): - _value, _key = self.client.get(*args, **kwargs) - if _key is None or _value is None: - return None - return EtcdEntry(_key, _value, value_in_json=value_in_json) - - def put(self, *args, value_in_json=False, **kwargs): - _key, _value = args - if value_in_json: - _value = json.dumps(_value) - - if not isinstance(_key, str): - _key = _key.decode("utf-8") - - return self.client.put(_key, _value, **kwargs) - - def get_prefix(self, *args, value_in_json=False, **kwargs): - r = self.client.get_prefix(*args, **kwargs) - for entry in r: - e = EtcdEntry(*entry[::-1], value_in_json=value_in_json) - if e.value: - yield e - - def watch_prefix(self, key, timeout=0, value_in_json=False): - timeout_event = EtcdEntry(PseudoEtcdMeta(key=b"TIMEOUT"), - value=str.encode(json.dumps({"status": "TIMEOUT", - "type": "TIMEOUT"})), - value_in_json=value_in_json) - - event_queue = queue.Queue() - - def add_event_to_queue(event): - for e in event.events: - if e.value: - event_queue.put(EtcdEntry(e, e.value, value_in_json=value_in_json)) - - self.client.add_watch_prefix_callback(key, add_event_to_queue) - - while True: - try: - while True: - v = event_queue.get(timeout=timeout) - yield v - except queue.Empty: - event_queue.put(copy.deepcopy(timeout_event)) - - -class PsuedoEtcdEntry(EtcdEntry): - def __init__(self, key, value, value_in_json=False): - super().__init__(PseudoEtcdMeta(key=key.encode("utf-8")), value, value_in_json=value_in_json) - +else: + log.warning('Configuration file not found - using defaults') etcd_wrapper_args = () etcd_wrapper_kwargs = { - 'host': config['etcd']['ETCD_URL'], - 'port': config['etcd']['ETCD_PORT'], - 'ca_cert': config['etcd']['CA_CERT'], - 'cert_cert': config['etcd']['CERT_CERT'], - 'cert_key': config['etcd']['CERT_KEY'] + 'host': config['etcd']['url'], + 'port': config['etcd']['port'], + 'ca_cert': config['etcd']['ca_cert'], + 'cert_cert': config['etcd']['cert_cert'], + 'cert_key': config['etcd']['cert_key'] } etcd_client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) -host_pool = HostPool(etcd_client, config['etcd']['HOST_PREFIX']) -vm_pool = VmPool(etcd_client, config['etcd']['VM_PREFIX']) -request_pool = RequestPool(etcd_client, config['etcd']['REQUEST_PREFIX']) +host_pool = HostPool(etcd_client, config['etcd']['host_prefix']) +vm_pool = VmPool(etcd_client, config['etcd']['vm_prefix']) +request_pool = RequestPool(etcd_client, config['etcd']['request_prefix']) running_vms = [] -__storage_backend = config['storage']["STORAGE_BACKEND"] -if __storage_backend == "filesystem": - image_storage_handler = FileSystemBasedImageStorageHandler(vm_base=config['storage']["VM_DIR"], - image_base=config['storage']["IMAGE_DIR"]) -elif __storage_backend == "ceph": - image_storage_handler = CEPHBasedImageStorageHandler(vm_base=config['storage']["CEPH_VM_POOL"], - image_base=config['storage']["CEPH_IMAGE_POOL"]) +__storage_backend = config['storage']['backend'] +if __storage_backend == 'filesystem': + image_storage_handler = FileSystemBasedImageStorageHandler( + vm_base=config['storage']['vm_dir'], + image_base=config['storage']['image_dir'] + ) +elif __storage_backend == 'ceph': + image_storage_handler = CEPHBasedImageStorageHandler( + vm_base=config['storage']['ceph_vm_pool'], + image_base=config['storage']['ceph_image_pool'] + ) else: - raise Exception("Unknown Image Storage Handler") + raise Exception('Unknown Image Storage Handler') diff --git a/ucloud/filescanner/main.py b/ucloud/filescanner/main.py index ef6fee6..265f9d9 100755 --- a/ucloud/filescanner/main.py +++ b/ucloud/filescanner/main.py @@ -3,6 +3,7 @@ import os import pathlib import subprocess as sp import time +import sys from uuid import uuid4 from . import logger @@ -19,7 +20,6 @@ def getxattr(file, attr): '--absolute-names'], stderr=sp.DEVNULL) value = value.decode("utf-8") except sp.CalledProcessError as e: - logger.exception(e) value = None return value @@ -63,14 +63,13 @@ try: sp.check_output(['which', 'getfattr']) sp.check_output(['which', 'setfattr']) except Exception as e: - logger.exception(e) - print('Make sure you have getfattr and setfattr available') - exit(1) + logger.error("You don't seems to have both getfattr and setfattr") + sys.exit(1) def main(): - BASE_DIR = config['storage']["FILE_DIR"] - FILE_PREFIX = config['storage']["FILE_PREFIX"] + BASE_DIR = config['storage']['file_dir'] + FILE_PREFIX = config['etcd']['file_prefix'] # Recursively Get All Files and Folder below BASE_DIR files = glob.glob("{}/**".format(BASE_DIR), recursive=True) @@ -79,7 +78,7 @@ def main(): files = list(filter(os.path.isfile, files)) untracked_files = list( - filter(lambda f: not bool(getxattr(f, "user.utracked")), files) + filter(lambda f: not bool(getxattr(f, "utracked")), files) ) tracked_files = list( @@ -89,7 +88,8 @@ def main(): file_id = uuid4() # Get Username - owner = pathlib.Path(file).parts[3] + owner = pathlib.Path(file).parts[len(pathlib.Path(BASE_DIR).parts)] + # Get Creation Date of File # Here, we are assuming that ctime is creation time # which is mostly not true. @@ -101,9 +101,7 @@ def main(): # Compute sha512 sum sha_sum = sha512sum(file) - # File Path excluding base and username - file_path = pathlib.Path(file).parts[4:] - file_path = os.path.join(*file_path) + file_path = pathlib.Path(file).parts[-1] # Create Entry entry_key = os.path.join(FILE_PREFIX, str(file_id)) @@ -115,10 +113,10 @@ def main(): "size": size } - print("Tracking {}".format(file)) + logger.info("Tracking %s", file) # Insert Entry etcd_client.put(entry_key, entry_value, value_in_json=True) - setxattr(file, "user.utracked", True) + setxattr(file, "utracked", True) if __name__ == "__main__": diff --git a/ucloud/host/main.py b/ucloud/host/main.py index 0ca345b..bd03a08 100755 --- a/ucloud/host/main.py +++ b/ucloud/host/main.py @@ -1,9 +1,10 @@ import argparse import multiprocessing as mp import time +import sys -from etcd3_wrapper import Etcd3Wrapper - +from os.path import isdir +from ucloud.common.etcd_wrapper import Etcd3Wrapper from ucloud.common.request import RequestEntry, RequestType from ucloud.config import (vm_pool, request_pool, etcd_client, running_vms, @@ -18,7 +19,7 @@ from ucloud.host import logger def update_heartbeat(hostname): """Update Last HeartBeat Time for :param hostname: in etcd""" client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) - host_pool = HostPool(client, config['etcd']['HOST_PREFIX']) + host_pool = HostPool(client, config['etcd']['host_prefix']) this_host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) while True: @@ -72,9 +73,11 @@ def maintenance(host): running_vms.remove(_vm) def check(): - if config['etcd']['STORAGE_BACKEND'] == 'filesystem' and not isdir(config['etcd']['VM_DIR']): + if config['storage']['backend'] == 'filesystem' and \ + not isdir(config['storage']['vm_dir']): + print("You have set STORAGE_BACKEND to filesystem. So, the vm directory mentioned" - " in .env file must exists. But, it don't.") + " in /etc/ucloud/ucloud.conf file must exists. But, it don't.") sys.exit(1) @@ -84,7 +87,7 @@ def main(hostname): heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(hostname,)) - host_pool = HostPool(etcd_client, config['etcd']['HOST_PREFIX']) + host_pool = HostPool(etcd_client, config['etcd']['host_prefix']) host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) assert host is not None, "No such host with name = {}".format(hostname) @@ -106,8 +109,8 @@ def main(hostname): # beat updating mechanism in separated thread for events_iterator in [ - etcd_client.get_prefix(config['etcd']['REQUEST_PREFIX'], value_in_json=True), - etcd_client.watch_prefix(config['etcd']['REQUEST_PREFIX'], timeout=10, value_in_json=True), + etcd_client.get_prefix(config['etcd']['request_prefix'], value_in_json=True), + etcd_client.watch_prefix(config['etcd']['request_prefix'], timeout=10, value_in_json=True), ]: for request_event in events_iterator: request_event = RequestEntry(request_event) diff --git a/ucloud/host/virtualmachine.py b/ucloud/host/virtualmachine.py index 33e4433..4a7584a 100755 --- a/ucloud/host/virtualmachine.py +++ b/ucloud/host/virtualmachine.py @@ -46,7 +46,7 @@ def delete_network_interface(iface): def resolve_network(network_name, network_owner): - network = etcd_client.get(join_path(config['etcd']["NETWORK_PREFIX"], + network = etcd_client.get(join_path(config['etcd']['network_prefix'], network_owner, network_name), value_in_json=True) @@ -179,7 +179,7 @@ def get_start_command_args(vm_entry, vnc_sock_filename: str, migration=False, mi for network_mac_and_tap in vm_networks: network_name, mac, tap = network_mac_and_tap - _key = os.path.join(config['etcd']['NETWORK_PREFIX'], vm_entry.owner, network_name) + _key = os.path.join(config['etcd']['network_prefix'], vm_entry.owner, network_name) network = etcd_client.get(_key, value_in_json=True) network_type = network.value["type"] network_id = str(network.value["id"]) @@ -187,7 +187,7 @@ def get_start_command_args(vm_entry, vnc_sock_filename: str, migration=False, mi if network_type == "vxlan": tap = create_vxlan_br_tap(_id=network_id, - _dev=config['etcd']["VXLAN_PHY_DEV"], + _dev=config['network']['vxlan_phy_dev'], tap_id=tap, ip=network_ipv6) update_radvd_conf(etcd_client) @@ -303,13 +303,13 @@ def transfer(request_event): _host, _port = request_event.parameters["host"], request_event.parameters["port"] _uuid = request_event.uuid _destination = request_event.destination_host_key - vm = get_vm(running_vms, join_path(config['etcd']['VM_PREFIX'], _uuid)) + vm = get_vm(running_vms, join_path(config['etcd']['vm_prefix'], _uuid)) if vm: tunnel = sshtunnel.SSHTunnelForwarder( _host, - ssh_username=config['ssh']["ssh_username"], - ssh_pkey=config['ssh']["SSH_PRIVATEKEY"], + ssh_username=config['ssh']['username'], + ssh_pkey=config['ssh']['private_key_path'], remote_bind_address=("127.0.0.1", _port), ssh_proxy_enabled=True, ssh_proxy=(_host, 22) @@ -373,7 +373,7 @@ def launch_vm(vm_entry, migration=False, migration_port=None, destination_host_k parameters={"host": get_ipv6_address(), "port": migration_port}, uuid=vm_entry.uuid, destination_host_key=destination_host_key, - request_prefix=config['etcd']["REQUEST_PREFIX"] + request_prefix=config['etcd']['request_prefix'] ) request_pool.put(r) else: diff --git a/ucloud/imagescanner/main.py b/ucloud/imagescanner/main.py index df4dfad..2658641 100755 --- a/ucloud/imagescanner/main.py +++ b/ucloud/imagescanner/main.py @@ -20,9 +20,9 @@ def qemu_img_type(path): def check(): """ check whether settings are sane, refuse to start if they aren't """ - if config['etcd']['STORAGE_BACKEND'] == 'filesystem' and not isdir(config['etcd']['IMAGE_DIR']): + if config['storage']['backend'] == 'filesystem' and not isdir(config['storage']['image_dir']): print("You have set STORAGE_BACKEND to filesystem, but " - "{} does not exist. Refusing to start".format(config['etcd']['IMAGE_DIR'])) + "{} does not exist. Refusing to start".format(config['storage']['image_dir'])) sys.exit(1) try: @@ -34,7 +34,7 @@ def check(): def main(): # We want to get images entries that requests images to be created - images = etcd_client.get_prefix(config['etcd']['IMAGE_PREFIX'], value_in_json=True) + images = etcd_client.get_prefix(config['etcd']['image_prefix'], value_in_json=True) images_to_be_created = list(filter(lambda im: im.value['status'] == 'TO_BE_CREATED', images)) for image in images_to_be_created: @@ -43,9 +43,10 @@ def main(): image_owner = image.value['owner'] image_filename = image.value['filename'] image_store_name = image.value['store_name'] - image_full_path = join_path(config['etcd']['BASE_DIR'], image_owner, image_filename) + image_full_path = join_path(config['storage']['file_dir'], image_owner, image_filename) - image_stores = etcd_client.get_prefix(config['etcd']['IMAGE_STORE_PREFIX'], value_in_json=True) + image_stores = etcd_client.get_prefix(config['etcd']['image_store_prefix'], + value_in_json=True) user_image_store = next(filter( lambda s, store_name=image_store_name: s.value["name"] == store_name, image_stores diff --git a/ucloud/metadata/main.py b/ucloud/metadata/main.py index 9281d7c..16b7c6d 100644 --- a/ucloud/metadata/main.py +++ b/ucloud/metadata/main.py @@ -43,7 +43,8 @@ class Root(Resource): if not data: return {'message': 'Metadata for such VM does not exists.'}, 404 else: - etcd_key = os.path.join(config['etcd']['USER_PREFIX'], data.value['owner_realm'], + etcd_key = os.path.join(config['etcd']['user_prefix'], + data.value['owner_realm'], data.value['owner'], 'key') etcd_entry = etcd_client.get_prefix(etcd_key, value_in_json=True) user_personal_ssh_keys = [key.value for key in etcd_entry] diff --git a/ucloud/scheduler/helper.py b/ucloud/scheduler/helper.py index 1754045..560bdbc 100755 --- a/ucloud/scheduler/helper.py +++ b/ucloud/scheduler/helper.py @@ -106,7 +106,7 @@ def assign_host(vm): r = RequestEntry.from_scratch(type=RequestType.StartVM, uuid=vm.uuid, hostname=vm.hostname, - request_prefix=config['etcd']['REQUEST_PREFIX']) + request_prefix=config['etcd']['request_prefix']) request_pool.put(r) vm.log.append("VM scheduled for starting") diff --git a/ucloud/scheduler/main.py b/ucloud/scheduler/main.py index 33e94f2..91a333e 100755 --- a/ucloud/scheduler/main.py +++ b/ucloud/scheduler/main.py @@ -18,8 +18,8 @@ def main(): pending_vms = [] for request_iterator in [ - etcd_client.get_prefix(config['etcd']['REQUEST_PREFIX'], value_in_json=True), - etcd_client.watch_prefix(config['etcd']['REQUEST_PREFIX'], timeout=5, value_in_json=True), + etcd_client.get_prefix(config['etcd']['request_prefix'], value_in_json=True), + etcd_client.watch_prefix(config['etcd']['request_prefix'], timeout=5, value_in_json=True), ]: for request_event in request_iterator: request_entry = RequestEntry(request_event) @@ -46,7 +46,7 @@ def main(): r = RequestEntry.from_scratch(type="ScheduleVM", uuid=pending_vm_entry.uuid, hostname=pending_vm_entry.hostname, - request_prefix=config['etcd']['REQUEST_PREFIX']) + request_prefix=config['etcd']['request_prefix']) request_pool.put(r) elif request_entry.type == RequestType.ScheduleVM: @@ -72,7 +72,7 @@ def main(): r = RequestEntry.from_scratch(type=RequestType.InitVMMigration, uuid=request_entry.uuid, destination=request_entry.destination, - request_prefix=config['etcd']['REQUEST_PREFIX']) + request_prefix=config['etcd']['request_prefix']) request_pool.put(r) # If the Request is about a VM that just want to get started/created From bc58a6ed9cceb60099cbdef9ac7ae8394e792c3f Mon Sep 17 00:00:00 2001 From: meow Date: Sat, 21 Dec 2019 14:36:55 +0500 Subject: [PATCH 077/284] Configuration/Setting module added --- conf/ucloud.conf | 46 ------------------- scripts/ucloud | 65 ++++++++++++++++++--------- ucloud/api/helper.py | 6 +-- ucloud/config.py | 30 ++++--------- ucloud/configure/__init__.py | 0 ucloud/configure/main.py | 67 ++++++++++++++++++++++++++++ ucloud/filescanner/main.py | 1 + ucloud/host/main.py | 10 ++--- ucloud/imagescanner/main.py | 10 +++-- ucloud/scheduler/main.py | 2 - ucloud/settings/__init__.py | 86 ++++++++++++++++++++++++++++++++++++ 11 files changed, 217 insertions(+), 106 deletions(-) create mode 100644 ucloud/configure/__init__.py create mode 100644 ucloud/configure/main.py create mode 100644 ucloud/settings/__init__.py diff --git a/conf/ucloud.conf b/conf/ucloud.conf index 222cc4f..334bbeb 100644 --- a/conf/ucloud.conf +++ b/conf/ucloud.conf @@ -1,19 +1,3 @@ -[otp] -server = https://otp.ungleich.ch/ungleichotp/ -verify_endpoint = verify/ -auth_name = replace_me -auth_realm = replace_me -auth_seed = replace_me - -[network] -prefix_length = 64 -prefix = 2001:db8::/48 -vxlan_phy_dev = eno1 - -[netbox] -url = https://replace-me.example.com -token = replace_me - [etcd] url = localhost port = 2379 @@ -21,33 +5,3 @@ port = 2379 ca_cert cert_cert cert_key - -file_prefix = /files/ -host_prefix = /hosts/ -image_prefix = /images/ -image_store_prefix = /imagestore/ -network_prefix = /networks/ -request_prefix = /requests/ -user_prefix = /users/ -vm_prefix = /vms/ - -[storage] - -#values = filesystem, ceph -backend = filesystem - -# if STORAGE_BACKEND = filesystem -vm_dir = /var/lib/ucloud/vms -image_dir = /var/lib/ucloud/images - -# if STORAGE_BACKEND = ceph -ceph_vm_pool = ssd -ceph_image_pool = ssd - -# Importing uploaded files -file_dir = /var/lib/ucloud/files - -# For Migrating VMs over ssh/tcp -[ssh] -username -private_key_path \ No newline at end of file diff --git a/scripts/ucloud b/scripts/ucloud index 2da1094..8ea6027 100755 --- a/scripts/ucloud +++ b/scripts/ucloud @@ -3,37 +3,60 @@ import argparse import logging import importlib -import sys import os import multiprocessing as mp +import sys + +from ucloud.configure.main import update_config, configure_parser -COMMANDS = ['api', 'scheduler', 'host', 'filescanner', 'imagescanner', 'metadata'] +def exception_hook(exc_type, exc_value, exc_traceback): + logger.error( + "Uncaught exception", + exc_info=(exc_type, exc_value, exc_traceback) + ) + print(exc_type, exc_value) -if __name__ == "__main__": + +if __name__ == '__main__': logging.basicConfig(level=logging.DEBUG, format='%(pathname)s:%(lineno)d -- %(levelname)-8s %(message)s', filename='/var/log/ucloud.log', filemode='a') - logger = logging.getLogger("ucloud") - arg_parser = argparse.ArgumentParser(prog='ucloud', - description='Open Source Cloud Management Software') - arg_parser.add_argument('-c', '--conf-dir', help="Configuration directory") - arg_parser.add_argument('component', choices=COMMANDS) - arg_parser.add_argument('component_args', nargs='*') + sys.excepthook = exception_hook + mp.set_start_method('spawn') + + arg_parser = argparse.ArgumentParser() + subparsers = arg_parser.add_subparsers(dest="command") + + api_parser = subparsers.add_parser("api") + + host_parser = subparsers.add_parser("host") + host_parser.add_argument("--hostname", required=True) + + scheduler_parser = subparsers.add_parser("scheduler") + + filescanner_parser = subparsers.add_parser("filescanner") + + imagescanner_parser = subparsers.add_parser("imagescanner") + + metadata_parser = subparsers.add_parser("metadata") + + config_parser = subparsers.add_parser("configure") + configure_parser(config_parser) args = arg_parser.parse_args() - if args.conf_dir: - os.environ['UCLOUD_CONF_DIR'] = args.conf_dir + if not args.command: + arg_parser.print_help() + else: + arguments = vars(args) + try: + name = arguments.pop('command') + mod = importlib.import_module("ucloud.{}.main".format(name)) + main = getattr(mod, "main") + main(**arguments) - try: - mp.set_start_method('spawn') - name = args.component - mod = importlib.import_module("ucloud.{}.main".format(name)) - main = getattr(mod, "main") - main(*args.component_args) - - except Exception as e: - logger.exception(e) - print(e) + except Exception as e: + logger.exception(e) + print(e) \ No newline at end of file diff --git a/ucloud/api/helper.py b/ucloud/api/helper.py index 2dfb7de..1448e02 100755 --- a/ucloud/api/helper.py +++ b/ucloud/api/helper.py @@ -29,11 +29,7 @@ def check_otp(name, realm, token): return 400 response = requests.post( - "{OTP_SERVER}{OTP_VERIFY_ENDPOINT}".format( - OTP_SERVER=config['otp']['server'], - OTP_VERIFY_ENDPOINT=config['otp']['verify_endpoint'] - ), - json=data, + config['otp']['verification_controller_url'], json=data ) return response.status_code diff --git a/ucloud/config.py b/ucloud/config.py index 3eee897..97d1561 100644 --- a/ucloud/config.py +++ b/ucloud/config.py @@ -5,32 +5,18 @@ import logging from ucloud.common.host import HostPool from ucloud.common.request import RequestPool from ucloud.common.vm import VmPool -from ucloud.common.storage_handlers import FileSystemBasedImageStorageHandler, CEPHBasedImageStorageHandler +from ucloud.common.storage_handlers import (FileSystemBasedImageStorageHandler, + CEPHBasedImageStorageHandler) from ucloud.common.etcd_wrapper import Etcd3Wrapper +from ucloud.settings import Settings +from os.path import join as join_path -log = logging.getLogger('ucloud.config') +logger = logging.getLogger('ucloud.config') -conf_name = 'ucloud.conf' -conf_dir = os.environ.get('UCLOUD_CONF_DIR', '/etc/ucloud') -config_file = os.path.join(conf_dir, conf_name) -config = configparser.ConfigParser(allow_no_value=True) -if os.access(config_file, os.R_OK): - config.read(config_file) -else: - log.warning('Configuration file not found - using defaults') - -etcd_wrapper_args = () -etcd_wrapper_kwargs = { - 'host': config['etcd']['url'], - 'port': config['etcd']['port'], - 'ca_cert': config['etcd']['ca_cert'], - 'cert_cert': config['etcd']['cert_cert'], - 'cert_key': config['etcd']['cert_key'] -} - -etcd_client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) +config = Settings() +etcd_client = config.get_etcd_client() host_pool = HostPool(etcd_client, config['etcd']['host_prefix']) vm_pool = VmPool(etcd_client, config['etcd']['vm_prefix']) @@ -38,7 +24,7 @@ request_pool = RequestPool(etcd_client, config['etcd']['request_prefix']) running_vms = [] -__storage_backend = config['storage']['backend'] +__storage_backend = config['storage']['storage_backend'] if __storage_backend == 'filesystem': image_storage_handler = FileSystemBasedImageStorageHandler( vm_base=config['storage']['vm_dir'], diff --git a/ucloud/configure/__init__.py b/ucloud/configure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ucloud/configure/main.py b/ucloud/configure/main.py new file mode 100644 index 0000000..0baa8eb --- /dev/null +++ b/ucloud/configure/main.py @@ -0,0 +1,67 @@ +import argparse +import sys +import os + +from ucloud.settings import Settings + +config = Settings() +etcd_client = config.get_etcd_client() + +def update_config(section, kwargs): + uncloud_config = etcd_client.get(config.config_key, + value_in_json=True) + if not uncloud_config: + uncloud_config = {} + else: + uncloud_config = uncloud_config.value + + uncloud_config[section] = kwargs + etcd_client.put(config.config_key, uncloud_config, value_in_json=True) + + +def configure_parser(parser): + configure_subparsers = parser.add_subparsers(dest="subcommand") + + otp_parser = configure_subparsers.add_parser("otp") + otp_parser.add_argument("--verification-controller-url", + required=True, metavar="URL") + otp_parser.add_argument("--auth-name", required=True, + metavar="OTP-NAME") + otp_parser.add_argument("--auth-realm", required=True, + metavar="OTP-REALM") + otp_parser.add_argument("--auth-seed", required=True, + metavar="OTP-SEED") + + network_parser = configure_subparsers.add_parser("network") + network_parser.add_argument("--prefix-length", required=True, type=int) + network_parser.add_argument("--prefix", required=True) + network_parser.add_argument("--vxlan-phy-dev", required=True) + + netbox_parser = configure_subparsers.add_parser("netbox") + netbox_parser.add_argument("--url", required=True) + netbox_parser.add_argument("--token", required=True) + + ssh_parser = configure_subparsers.add_parser("ssh") + ssh_parser.add_argument('--username', default="root") + ssh_parser.add_argument('--private-key-path', + default=os.path.expanduser("~/.ssh/id_rsa")) + + storage_parser = configure_subparsers.add_parser("storage") + storage_parser.add_argument('--file-dir', required=True) + storage_parser_subparsers = storage_parser.add_subparsers(dest="storage_backend") + + filesystem_storage_parser = storage_parser_subparsers.add_parser("filesystem") + filesystem_storage_parser.add_argument('--vm-dir', required=True) + filesystem_storage_parser.add_argument('--image-dir', required=True) + + ceph_storage_parser = storage_parser_subparsers.add_parser("ceph") + ceph_storage_parser.add_argument('--ceph-vm-pool', required=True) + ceph_storage_parser.add_argument('--ceph-image-pool', required=True) + + +def main(**kwargs): + subcommand = kwargs.pop('subcommand') + if not subcommand: + pass + else: + update_config(subcommand, kwargs) diff --git a/ucloud/filescanner/main.py b/ucloud/filescanner/main.py index 265f9d9..14a77cf 100755 --- a/ucloud/filescanner/main.py +++ b/ucloud/filescanner/main.py @@ -4,6 +4,7 @@ import pathlib import subprocess as sp import time import sys + from uuid import uuid4 from . import logger diff --git a/ucloud/host/main.py b/ucloud/host/main.py index bd03a08..ddc52c7 100755 --- a/ucloud/host/main.py +++ b/ucloud/host/main.py @@ -8,17 +8,16 @@ from ucloud.common.etcd_wrapper import Etcd3Wrapper from ucloud.common.request import RequestEntry, RequestType from ucloud.config import (vm_pool, request_pool, etcd_client, running_vms, - etcd_wrapper_args, etcd_wrapper_kwargs, HostPool, config) from .helper import find_free_port -from . import virtualmachine -from ucloud.host import logger +from . import virtualmachine, logger def update_heartbeat(hostname): """Update Last HeartBeat Time for :param hostname: in etcd""" - client = Etcd3Wrapper(*etcd_wrapper_args, **etcd_wrapper_kwargs) + + client = config.get_etcd_client() host_pool = HostPool(client, config['etcd']['host_prefix']) this_host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) @@ -73,7 +72,7 @@ def maintenance(host): running_vms.remove(_vm) def check(): - if config['storage']['backend'] == 'filesystem' and \ + if config['storage']['storage_backend'] == 'filesystem' and \ not isdir(config['storage']['vm_dir']): print("You have set STORAGE_BACKEND to filesystem. So, the vm directory mentioned" @@ -81,7 +80,6 @@ def check(): sys.exit(1) - def main(hostname): check() diff --git a/ucloud/imagescanner/main.py b/ucloud/imagescanner/main.py index 2658641..135f8cb 100755 --- a/ucloud/imagescanner/main.py +++ b/ucloud/imagescanner/main.py @@ -1,7 +1,9 @@ import json import os import subprocess +import sys +from os.path import isdir from os.path import join as join_path from ucloud.config import etcd_client, config, image_storage_handler from ucloud.imagescanner import logger @@ -20,10 +22,10 @@ def qemu_img_type(path): def check(): """ check whether settings are sane, refuse to start if they aren't """ - if config['storage']['backend'] == 'filesystem' and not isdir(config['storage']['image_dir']): - print("You have set STORAGE_BACKEND to filesystem, but " - "{} does not exist. Refusing to start".format(config['storage']['image_dir'])) - sys.exit(1) + if config['storage']['storage_backend'] == 'filesystem' and not isdir(config['storage']['image_dir']): + sys.exit("You have set STORAGE_BACKEND to filesystem, but " + "{} does not exist. Refusing to start".format(config['storage']['image_dir']) + ) try: subprocess.check_output(['which', 'qemu-img']) diff --git a/ucloud/scheduler/main.py b/ucloud/scheduler/main.py index 91a333e..49d6291 100755 --- a/ucloud/scheduler/main.py +++ b/ucloud/scheduler/main.py @@ -13,8 +13,6 @@ from . import logger def main(): - logger.info("%s SESSION STARTED %s", '*' * 5, '*' * 5) - pending_vms = [] for request_iterator in [ diff --git a/ucloud/settings/__init__.py b/ucloud/settings/__init__.py new file mode 100644 index 0000000..5f29c41 --- /dev/null +++ b/ucloud/settings/__init__.py @@ -0,0 +1,86 @@ +import configparser +import logging +import sys +import os + +from ucloud.common.etcd_wrapper import Etcd3Wrapper + + +logger = logging.getLogger(__name__) + + +class CustomConfigParser(configparser.RawConfigParser): + def __getitem__(self, key): + try: + result = super().__getitem__(key) + except KeyError as err: + raise KeyError("Key '{}' not found in config file"\ + .format(key)) from err + else: + return result + + +class Settings(object): + def __init__(self, config_key='/uncloud/config/'): + conf_name = 'ucloud.conf' + conf_dir = os.environ.get('UCLOUD_CONF_DIR', '/etc/ucloud') + config_file = os.path.join(conf_dir, conf_name) + + self.config_parser = CustomConfigParser(allow_no_value=True) + self.config_key = config_key + + self.read_internal_values() + self.read_config_file_values(config_file) + + self.etcd_wrapper_args = tuple() + self.etcd_wrapper_kwargs = { + 'host': self.config_parser['etcd']['url'], + 'port': self.config_parser['etcd']['port'], + 'ca_cert': self.config_parser['etcd']['ca_cert'], + 'cert_cert': self.config_parser['etcd']['cert_cert'], + 'cert_key': self.config_parser['etcd']['cert_key'] + } + + + def get_etcd_client(self): + args = self.etcd_wrapper_args + kwargs = self.etcd_wrapper_kwargs + return Etcd3Wrapper(*args, **kwargs) + + def read_internal_values(self): + self.config_parser.read_dict({ + 'etcd': { + 'file_prefix': '/files/', + 'host_prefix': '/hosts/', + 'image_prefix': '/images/', + 'image_store_prefix': '/imagestore/', + 'network_prefix': '/networks/', + 'request_prefix': '/requests/', + 'user_prefix': '/users/', + 'vm_prefix': '/vms/', + } + }) + + def read_config_file_values(self, config_file): + try: + # Trying to read configuration file + with open(config_file, "r") as config_file_handle: + self.config_parser.read_file(config_file_handle) + except FileNotFoundError: + sys.exit('Configuration file {} not found!'.format(config_file)) + except Exception as err: + logger.exception(err) + sys.exit("Error occurred while reading configuration file") + + def read_values_from_etcd(self): + etcd_client = self.get_etcd_client() + config_from_etcd = etcd_client.get(self.config_key, value_in_json=True) + if config_from_etcd: + self.config_parser.read_dict(config_from_etcd.value) + else: + return + sys.exit("No settings found in etcd at key {}".format(self.config_key)) + + def __getitem__(self, key): + self.read_values_from_etcd() + return self.config_parser[key] From 04993e41067edccd1fef3b6b48b4e8062f884c9f Mon Sep 17 00:00:00 2001 From: meow Date: Sun, 22 Dec 2019 12:26:48 +0500 Subject: [PATCH 078/284] Refactoring, Removal of most global vars, config default path is ~/ucloud/ --- scripts/ucloud | 31 +- setup.py | 4 +- ucloud/api/common_fields.py | 6 +- ucloud/api/create_image_store.py | 5 +- ucloud/api/helper.py | 18 +- ucloud/api/main.py | 118 +++--- ucloud/api/schemas.py | 23 +- ucloud/common/etcd_wrapper.py | 40 +- ucloud/common/network.py | 60 +++ ucloud/common/storage_handlers.py | 18 + ucloud/config.py | 39 -- ucloud/configure/main.py | 10 +- ucloud/filescanner/main.py | 17 +- ucloud/host/helper.py | 13 - ucloud/host/main.py | 110 +---- ucloud/host/virtualmachine.py | 648 +++++++++++++++--------------- ucloud/imagescanner/main.py | 19 +- ucloud/metadata/main.py | 10 +- ucloud/scheduler/helper.py | 25 +- ucloud/scheduler/main.py | 24 +- ucloud/scheduler/main.py.old | 93 ----- ucloud/settings/__init__.py | 38 +- ucloud/shared/__init__.py | 30 ++ 23 files changed, 673 insertions(+), 726 deletions(-) create mode 100644 ucloud/common/network.py delete mode 100644 ucloud/config.py delete mode 100644 ucloud/host/helper.py delete mode 100755 ucloud/scheduler/main.py.old create mode 100644 ucloud/shared/__init__.py diff --git a/scripts/ucloud b/scripts/ucloud index 8ea6027..7f3ef3a 100755 --- a/scripts/ucloud +++ b/scripts/ucloud @@ -3,29 +3,23 @@ import argparse import logging import importlib -import os import multiprocessing as mp import sys -from ucloud.configure.main import update_config, configure_parser +from ucloud.configure.main import configure_parser def exception_hook(exc_type, exc_value, exc_traceback): - logger.error( - "Uncaught exception", - exc_info=(exc_type, exc_value, exc_traceback) - ) - print(exc_type, exc_value) + logger.error( + 'Uncaught exception', + exc_info=(exc_type, exc_value, exc_traceback) + ) + print('Error: ', end='') + print(exc_type, exc_value, exc_traceback) if __name__ == '__main__': - logging.basicConfig(level=logging.DEBUG, - format='%(pathname)s:%(lineno)d -- %(levelname)-8s %(message)s', - filename='/var/log/ucloud.log', filemode='a') - logger = logging.getLogger("ucloud") - sys.excepthook = exception_hook - mp.set_start_method('spawn') arg_parser = argparse.ArgumentParser() subparsers = arg_parser.add_subparsers(dest="command") @@ -50,13 +44,20 @@ if __name__ == '__main__': if not args.command: arg_parser.print_help() else: + logging.basicConfig( + level=logging.DEBUG, + format='%(pathname)s:%(lineno)d -- %(levelname)-8s %(message)s', + handlers=[logging.handlers.SysLogHandler(address = '/dev/log')] + ) + logger = logging.getLogger("ucloud") + mp.set_start_method('spawn') + arguments = vars(args) try: name = arguments.pop('command') mod = importlib.import_module("ucloud.{}.main".format(name)) main = getattr(mod, "main") main(**arguments) - except Exception as e: - logger.exception(e) + logger.exception('Error') print(e) \ No newline at end of file diff --git a/setup.py b/setup.py index a7ddbe6..e273d68 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,5 @@ +import os + from setuptools import setup, find_packages with open("README.md", "r") as fh: @@ -39,5 +41,5 @@ setup(name='ucloud', 'etcd3 @ https://github.com/kragniz/python-etcd3/tarball/master#egg=etcd3', ], scripts=['scripts/ucloud'], - data_files=[('/etc/ucloud/', ['conf/ucloud.conf'])], + data_files=[(os.path.expanduser('~/ucloud/'), ['conf/ucloud.conf'])], zip_safe=False) diff --git a/ucloud/api/common_fields.py b/ucloud/api/common_fields.py index cf86283..a793d26 100755 --- a/ucloud/api/common_fields.py +++ b/ucloud/api/common_fields.py @@ -1,6 +1,8 @@ import os -from ucloud.config import etcd_client, config +from ucloud.shared import shared +from ucloud.settings import settings + class Optional: pass @@ -47,6 +49,6 @@ class VmUUIDField(Field): self.validation = self.vm_uuid_validation def vm_uuid_validation(self): - r = etcd_client.get(os.path.join(config['etcd']['vm_prefix'], self.uuid)) + r = shared.etcd_client.get(os.path.join(settings['etcd']['vm_prefix'], self.uuid)) if not r: self.add_error("VM with uuid {} does not exists".format(self.uuid)) diff --git a/ucloud/api/create_image_store.py b/ucloud/api/create_image_store.py index 9023bd6..978a182 100755 --- a/ucloud/api/create_image_store.py +++ b/ucloud/api/create_image_store.py @@ -3,7 +3,8 @@ import os from uuid import uuid4 -from ucloud.config import etcd_client, config +from ucloud.shared import shared +from ucloud.settings import settings data = { "is_public": True, @@ -13,4 +14,4 @@ data = { "attributes": {"list": [], "key": [], "pool": "images"}, } -etcd_client.put(os.path.join(config['etcd']['image_store_prefix'], uuid4().hex), json.dumps(data)) +shared.etcd_client.put(os.path.join(settings['etcd']['image_store_prefix'], uuid4().hex), json.dumps(data)) diff --git a/ucloud/api/helper.py b/ucloud/api/helper.py index 1448e02..9cda36e 100755 --- a/ucloud/api/helper.py +++ b/ucloud/api/helper.py @@ -7,29 +7,29 @@ import requests from pyotp import TOTP -from ucloud.config import vm_pool, config - +from ucloud.shared import shared +from ucloud.settings import settings logger = logging.getLogger("ucloud.api.helper") def check_otp(name, realm, token): try: data = { - "auth_name": config['otp']['auth_name'], - "auth_token": TOTP(config['otp']['auth_seed']).now(), - "auth_realm": config['otp']['auth_realm'], + "auth_name": settings['otp']['auth_name'], + "auth_token": TOTP(settings['otp']['auth_seed']).now(), + "auth_realm": settings['otp']['auth_realm'], "name": name, "realm": realm, "token": token, } except binascii.Error as err: logger.error( - "Cannot compute OTP for seed: {}".format(config['otp']['auth_seed']) + "Cannot compute OTP for seed: {}".format(settings['otp']['auth_seed']) ) return 400 response = requests.post( - config['otp']['verification_controller_url'], json=data + settings['otp']['verification_controller_url'], json=data ) return response.status_code @@ -43,7 +43,7 @@ def resolve_vm_name(name, owner): result = next( filter( lambda vm: vm.value["owner"] == owner and vm.value["name"] == name, - vm_pool.vms, + shared.vm_pool.vms, ), None, ) @@ -81,7 +81,7 @@ def resolve_image_name(name, etcd_client): except Exception: raise ValueError("Image name not in correct format i.e {store_name}:{image_name}") - images = etcd_client.get_prefix(config['etcd']['image_prefix'], value_in_json=True) + images = etcd_client.get_prefix(settings['etcd']['image_prefix'], value_in_json=True) # Try to find image with name == image_name and store_name == store_name try: diff --git a/ucloud/api/main.py b/ucloud/api/main.py index 0e202d8..05972ff 100644 --- a/ucloud/api/main.py +++ b/ucloud/api/main.py @@ -10,10 +10,9 @@ from flask_restful import Resource, Api from ucloud.common import counters from ucloud.common.vm import VMStatus from ucloud.common.request import RequestEntry, RequestType -from ucloud.config import ( - etcd_client, request_pool, vm_pool, - host_pool, config, image_storage_handler -) +from ucloud.settings import settings +from ucloud.shared import shared + from . import schemas from .helper import generate_mac, mac2ipv6 from . import logger @@ -31,7 +30,7 @@ class CreateVM(Resource): validator = schemas.CreateVMSchema(data) if validator.is_valid(): vm_uuid = uuid4().hex - vm_key = join_path(config['etcd']['vm_prefix'], vm_uuid) + vm_key = join_path(settings['etcd']['vm_prefix'], vm_uuid) specs = { "cpu": validator.specs["cpu"], "ram": validator.specs["ram"], @@ -39,7 +38,7 @@ class CreateVM(Resource): "hdd": validator.specs["hdd"], } macs = [generate_mac() for _ in range(len(data["network"]))] - tap_ids = [counters.increment_etcd_counter(etcd_client, "/v1/counter/tap") + tap_ids = [counters.increment_etcd_counter(shared.etcd_client, "/v1/counter/tap") for _ in range(len(data["network"]))] vm_entry = { "name": data["vm_name"], @@ -54,14 +53,14 @@ class CreateVM(Resource): "network": list(zip(data["network"], macs, tap_ids)), "metadata": {"ssh-keys": []}, } - etcd_client.put(vm_key, vm_entry, value_in_json=True) + shared.etcd_client.put(vm_key, vm_entry, value_in_json=True) # Create ScheduleVM Request r = RequestEntry.from_scratch( type=RequestType.ScheduleVM, uuid=vm_uuid, - request_prefix=config['etcd']['request_prefix'] + request_prefix=settings['etcd']['request_prefix'] ) - request_pool.put(r) + shared.request_pool.put(r) return {"message": "VM Creation Queued"}, 200 return validator.get_errors(), 400 @@ -73,16 +72,16 @@ class VmStatus(Resource): data = request.json validator = schemas.VMStatusSchema(data) if validator.is_valid(): - vm = vm_pool.get( - join_path(config['etcd']['vm_prefix'], data["uuid"]) + vm = shared.vm_pool.get( + join_path(settings['etcd']['vm_prefix'], data["uuid"]) ) vm_value = vm.value.copy() vm_value["ip"] = [] for network_mac_and_tap in vm.network: network_name, mac, tap = network_mac_and_tap - network = etcd_client.get( + network = shared.etcd_client.get( join_path( - config['etcd']['network_prefix'], + settings['etcd']['network_prefix'], data["name"], network_name, ), @@ -102,8 +101,8 @@ class CreateImage(Resource): data = request.json validator = schemas.CreateImageSchema(data) if validator.is_valid(): - file_entry = etcd_client.get( - join_path(config['etcd']['file_prefix'], data["uuid"]) + file_entry = shared.etcd_client.get( + join_path(settings['etcd']['file_prefix'], data["uuid"]) ) file_entry_value = json.loads(file_entry.value) @@ -115,8 +114,8 @@ class CreateImage(Resource): "store_name": data["image_store"], "visibility": "public", } - etcd_client.put( - join_path(config['etcd']['image_prefix'], data["uuid"]), + shared.etcd_client.put( + join_path(settings['etcd']['image_prefix'], data["uuid"]), json.dumps(image_entry_json), ) @@ -127,8 +126,8 @@ class CreateImage(Resource): class ListPublicImages(Resource): @staticmethod def get(): - images = etcd_client.get_prefix( - config['etcd']['image_prefix'], value_in_json=True + images = shared.etcd_client.get_prefix( + settings['etcd']['image_prefix'], value_in_json=True ) r = { "images": [] @@ -150,8 +149,8 @@ class VMAction(Resource): validator = schemas.VmActionSchema(data) if validator.is_valid(): - vm_entry = vm_pool.get( - join_path(config['etcd']['vm_prefix'], data["uuid"]) + vm_entry = shared.vm_pool.get( + join_path(settings['etcd']['vm_prefix'], data["uuid"]) ) action = data["action"] @@ -159,25 +158,25 @@ class VMAction(Resource): action = "schedule" if action == "delete" and vm_entry.hostname == "": - if image_storage_handler.is_vm_image_exists(vm_entry.uuid): - r_status = image_storage_handler.delete_vm_image(vm_entry.uuid) + if shared.storage_handler.is_vm_image_exists(vm_entry.uuid): + r_status = shared.storage_handler.delete_vm_image(vm_entry.uuid) if r_status: - etcd_client.client.delete(vm_entry.key) + shared.etcd_client.client.delete(vm_entry.key) return {"message": "VM successfully deleted"} else: logger.error("Some Error Occurred while deleting VM") return {"message": "VM deletion unsuccessfull"} else: - etcd_client.client.delete(vm_entry.key) + shared.etcd_client.client.delete(vm_entry.key) return {"message": "VM successfully deleted"} r = RequestEntry.from_scratch( type="{}VM".format(action.title()), uuid=data["uuid"], hostname=vm_entry.hostname, - request_prefix=config['etcd']['request_prefix'] + request_prefix=settings['etcd']['request_prefix'] ) - request_pool.put(r) + shared.request_pool.put(r) return {"message": "VM {} Queued".format(action.title())}, 200 else: return validator.get_errors(), 400 @@ -190,18 +189,18 @@ class VMMigration(Resource): validator = schemas.VmMigrationSchema(data) if validator.is_valid(): - vm = vm_pool.get(data["uuid"]) + vm = shared.vm_pool.get(data["uuid"]) r = RequestEntry.from_scratch( type=RequestType.ScheduleVM, uuid=vm.uuid, destination=join_path( - config['etcd']['host_prefix'], validator.destination.value + settings['etcd']['host_prefix'], validator.destination.value ), migration=True, - request_prefix=config['etcd']['request_prefix'] + request_prefix=settings['etcd']['request_prefix'] ) - request_pool.put(r) + shared.request_pool.put(r) return {"message": "VM Migration Initialization Queued"}, 200 else: return validator.get_errors(), 400 @@ -214,8 +213,8 @@ class ListUserVM(Resource): validator = schemas.OTPSchema(data) if validator.is_valid(): - vms = etcd_client.get_prefix( - config['etcd']['vm_prefix'], value_in_json=True + vms = shared.etcd_client.get_prefix( + settings['etcd']['vm_prefix'], value_in_json=True ) return_vms = [] user_vms = filter(lambda v: v.value["owner"] == data["name"], vms) @@ -227,7 +226,6 @@ class ListUserVM(Resource): "specs": vm.value["specs"], "status": vm.value["status"], "hostname": vm.value["hostname"], - # "mac": vm.value["mac"], "vnc_socket": None if vm.value.get("vnc_socket", None) is None else vm.value["vnc_socket"], @@ -248,8 +246,8 @@ class ListUserFiles(Resource): validator = schemas.OTPSchema(data) if validator.is_valid(): - files = etcd_client.get_prefix( - config['etcd']['file_prefix'], value_in_json=True + files = shared.etcd_client.get_prefix( + settings['etcd']['file_prefix'], value_in_json=True ) return_files = [] user_files = list( @@ -273,14 +271,14 @@ class CreateHost(Resource): data = request.json validator = schemas.CreateHostSchema(data) if validator.is_valid(): - host_key = join_path(config['etcd']['host_prefix'], uuid4().hex) + host_key = join_path(settings['etcd']['host_prefix'], uuid4().hex) host_entry = { "specs": data["specs"], "hostname": data["hostname"], "status": "DEAD", "last_heartbeat": "", } - etcd_client.put(host_key, host_entry, value_in_json=True) + shared.etcd_client.put(host_key, host_entry, value_in_json=True) return {"message": "Host Created"}, 200 @@ -290,7 +288,7 @@ class CreateHost(Resource): class ListHost(Resource): @staticmethod def get(): - hosts = host_pool.hosts + hosts = shared.host_pool.hosts r = { host.key: { "status": host.status, @@ -312,12 +310,12 @@ class GetSSHKeys(Resource): # {user_prefix}/{realm}/{name}/key/ etcd_key = join_path( - config['etcd']['user_prefix'], + settings['etcd']['user_prefix'], data["realm"], data["name"], "key", ) - etcd_entry = etcd_client.get_prefix( + etcd_entry = shared.etcd_client.get_prefix( etcd_key, value_in_json=True ) @@ -329,13 +327,13 @@ class GetSSHKeys(Resource): # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - config['etcd']['user_prefix'], + settings['etcd']['user_prefix'], data["realm"], data["name"], "key", data["key_name"], ) - etcd_entry = etcd_client.get(etcd_key, value_in_json=True) + etcd_entry = shared.etcd_client.get(etcd_key, value_in_json=True) if etcd_entry: return { @@ -358,13 +356,13 @@ class AddSSHKey(Resource): # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - config['etcd']['user_prefix'], + settings['etcd']['user_prefix'], data["realm"], data["name"], "key", data["key_name"], ) - etcd_entry = etcd_client.get(etcd_key, value_in_json=True) + etcd_entry = shared.etcd_client.get(etcd_key, value_in_json=True) if etcd_entry: return { "message": "Key with name '{}' already exists".format( @@ -373,7 +371,7 @@ class AddSSHKey(Resource): } else: # Key Not Found. It implies user' haven't added any key yet. - etcd_client.put(etcd_key, data["key"], value_in_json=True) + shared.etcd_client.put(etcd_key, data["key"], value_in_json=True) return {"message": "Key added successfully"} else: return validator.get_errors(), 400 @@ -388,15 +386,15 @@ class RemoveSSHKey(Resource): # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - config['etcd']['user_prefix'], + settings['etcd']['user_prefix'], data["realm"], data["name"], "key", data["key_name"], ) - etcd_entry = etcd_client.get(etcd_key, value_in_json=True) + etcd_entry = shared.etcd_client.get(etcd_key, value_in_json=True) if etcd_entry: - etcd_client.client.delete(etcd_key) + shared.etcd_client.client.delete(etcd_key) return {"message": "Key successfully removed."} else: return { @@ -418,22 +416,22 @@ class CreateNetwork(Resource): network_entry = { "id": counters.increment_etcd_counter( - etcd_client, "/v1/counter/vxlan" + shared.etcd_client, "/v1/counter/vxlan" ), "type": data["type"], } if validator.user.value: try: nb = pynetbox.api( - url=config['netbox']['url'], - token=config['netbox']['token'], + url=settings['netbox']['url'], + token=settings['netbox']['token'], ) nb_prefix = nb.ipam.prefixes.get( - prefix=config['network']['prefix'] + prefix=settings['network']['prefix'] ) prefix = nb_prefix.available_prefixes.create( data={ - "prefix_length": int(config['network']['prefix_length']), + "prefix_length": int(settings['network']['prefix_length']), "description": '{}\'s network "{}"'.format( data["name"], data["network_name"] ), @@ -449,11 +447,11 @@ class CreateNetwork(Resource): network_entry["ipv6"] = "fd00::/64" network_key = join_path( - config['etcd']['network_prefix'], + settings['etcd']['network_prefix'], data['name'], data['network_name'], ) - etcd_client.put(network_key, network_entry, value_in_json=True) + shared.etcd_client.put(network_key, network_entry, value_in_json=True) return {"message": "Network successfully added."} else: return validator.get_errors(), 400 @@ -467,9 +465,9 @@ class ListUserNetwork(Resource): if validator.is_valid(): prefix = join_path( - config['etcd']['network_prefix'], data["name"] + settings['etcd']['network_prefix'], data["name"] ) - networks = etcd_client.get_prefix(prefix, value_in_json=True) + networks = shared.etcd_client.get_prefix(prefix, value_in_json=True) user_networks = [] for net in networks: net.value["name"] = net.key.split("/")[-1] @@ -503,7 +501,7 @@ api.add_resource(CreateNetwork, "/network/create") def main(): - image_stores = list(etcd_client.get_prefix(config['etcd']['image_store_prefix'], value_in_json=True)) + image_stores = list(shared.etcd_client.get_prefix(settings['etcd']['image_store_prefix'], value_in_json=True)) if len(image_stores) == 0: data = { "is_public": True, @@ -513,7 +511,7 @@ def main(): "attributes": {"list": [], "key": [], "pool": "images"}, } - etcd_client.put(join_path(config['etcd']['image_store_prefix'], uuid4().hex), json.dumps(data)) + shared.etcd_client.put(join_path(settings['etcd']['image_store_prefix'], uuid4().hex), json.dumps(data)) app.run(host="::", debug=True) diff --git a/ucloud/api/schemas.py b/ucloud/api/schemas.py index a3e0aa8..d639be4 100755 --- a/ucloud/api/schemas.py +++ b/ucloud/api/schemas.py @@ -21,7 +21,8 @@ import bitmath from ucloud.common.host import HostStatus from ucloud.common.vm import VMStatus -from ucloud.config import etcd_client, config, vm_pool, host_pool +from ucloud.shared import shared +from ucloud.settings import settings from . import helper, logger from .common_fields import Field, VmUUIDField from .helper import check_otp, resolve_vm_name @@ -102,14 +103,14 @@ class CreateImageSchema(BaseSchema): super().__init__(data, fields) def file_uuid_validation(self): - file_entry = etcd_client.get(os.path.join(config['etcd']['file_prefix'], self.uuid.value)) + file_entry = shared.etcd_client.get(os.path.join(settings['etcd']['file_prefix'], self.uuid.value)) if file_entry is None: self.add_error( "Image File with uuid '{}' Not Found".format(self.uuid.value) ) def image_store_name_validation(self): - image_stores = list(etcd_client.get_prefix(config['etcd']['image_store_prefix'])) + image_stores = list(shared.etcd_client.get_prefix(settings['etcd']['image_store_prefix'])) image_store = next( filter( @@ -218,7 +219,7 @@ class CreateVMSchema(OTPSchema): def image_validation(self): try: - image_uuid = helper.resolve_image_name(self.image.value, etcd_client) + image_uuid = helper.resolve_image_name(self.image.value, shared.etcd_client) except Exception as e: logger.exception("Cannot resolve image name = %s", self.image.value) self.add_error(str(e)) @@ -236,7 +237,7 @@ class CreateVMSchema(OTPSchema): if _network: for net in _network: - network = etcd_client.get(os.path.join(config['etcd']['network_prefix'], + network = shared.etcd_client.get(os.path.join(settings['etcd']['network_prefix'], self.name.value, net), value_in_json=True) if not network: @@ -310,7 +311,7 @@ class VMStatusSchema(OTPSchema): super().__init__(data, fields) def validation(self): - vm = vm_pool.get(self.uuid.value) + vm = shared.vm_pool.get(self.uuid.value) if not ( vm.value["owner"] == self.name.value or self.realm.value == "ungleich-admin" ): @@ -343,7 +344,7 @@ class VmActionSchema(OTPSchema): ) def validation(self): - vm = vm_pool.get(self.uuid.value) + vm = shared.vm_pool.get(self.uuid.value) if not ( vm.value["owner"] == self.name.value or self.realm.value == "ungleich-admin" ): @@ -383,7 +384,7 @@ class VmMigrationSchema(OTPSchema): def destination_validation(self): hostname = self.destination.value - host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) + host = next(filter(lambda h: h.hostname == hostname, shared.host_pool.hosts), None) if not host: self.add_error("No Such Host ({}) exists".format(self.destination.value)) elif host.status != HostStatus.alive: @@ -392,7 +393,7 @@ class VmMigrationSchema(OTPSchema): self.destination.value = host.key def validation(self): - vm = vm_pool.get(self.uuid.value) + vm = shared.vm_pool.get(self.uuid.value) if not ( vm.value["owner"] == self.name.value or self.realm.value == "ungleich-admin" ): @@ -401,7 +402,7 @@ class VmMigrationSchema(OTPSchema): if vm.status != VMStatus.running: self.add_error("Can't migrate non-running VM") - if vm.hostname == os.path.join(config['etcd']['host_prefix'], self.destination.value): + if vm.hostname == os.path.join(settings['etcd']['host_prefix'], self.destination.value): self.add_error("Destination host couldn't be same as Source Host") @@ -443,7 +444,7 @@ class CreateNetwork(OTPSchema): super().__init__(data, fields=fields) def network_name_validation(self): - network = etcd_client.get(os.path.join(config['etcd']['network_prefix'], + network = shared.etcd_client.get(os.path.join(settings['etcd']['network_prefix'], self.name.value, self.network_name.value), value_in_json=True) diff --git a/ucloud/common/etcd_wrapper.py b/ucloud/common/etcd_wrapper.py index a3fb83f..e249e6c 100644 --- a/ucloud/common/etcd_wrapper.py +++ b/ucloud/common/etcd_wrapper.py @@ -4,40 +4,63 @@ import queue import copy from collections import namedtuple +from functools import wraps + +from . import logger + +PseudoEtcdMeta = namedtuple('PseudoEtcdMeta', ['key']) -PseudoEtcdMeta = namedtuple("PseudoEtcdMeta", ["key"]) class EtcdEntry: # key: str # value: str def __init__(self, meta, value, value_in_json=False): - self.key = meta.key.decode("utf-8") - self.value = value.decode("utf-8") + self.key = meta.key.decode('utf-8') + self.value = value.decode('utf-8') if value_in_json: self.value = json.loads(self.value) + +def readable_errors(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + func(*args, **kwargs) + except etcd3.exceptions.ConnectionFailedError as err: + raise etcd3.exceptions.ConnectionFailedError('etcd connection failed') from err + except etcd3.exceptions.ConnectionTimeoutError as err: + raise etcd3.exceptions.ConnectionTimeoutError('etcd connection timeout') from err + except Exception: + print('Some error occurred, most probably it is etcd that is erroring out.') + logger.exception('Some etcd error occurred') + return wrapper + + class Etcd3Wrapper: def __init__(self, *args, **kwargs): self.client = etcd3.client(*args, **kwargs) + @readable_errors def get(self, *args, value_in_json=False, **kwargs): _value, _key = self.client.get(*args, **kwargs) if _key is None or _value is None: return None return EtcdEntry(_key, _value, value_in_json=value_in_json) + @readable_errors def put(self, *args, value_in_json=False, **kwargs): _key, _value = args if value_in_json: _value = json.dumps(_value) if not isinstance(_key, str): - _key = _key.decode("utf-8") + _key = _key.decode('utf-8') return self.client.put(_key, _value, **kwargs) + @readable_errors def get_prefix(self, *args, value_in_json=False, **kwargs): r = self.client.get_prefix(*args, **kwargs) for entry in r: @@ -45,10 +68,11 @@ class Etcd3Wrapper: if e.value: yield e + @readable_errors def watch_prefix(self, key, timeout=0, value_in_json=False): - timeout_event = EtcdEntry(PseudoEtcdMeta(key=b"TIMEOUT"), - value=str.encode(json.dumps({"status": "TIMEOUT", - "type": "TIMEOUT"})), + timeout_event = EtcdEntry(PseudoEtcdMeta(key=b'TIMEOUT'), + value=str.encode(json.dumps({'status': 'TIMEOUT', + 'type': 'TIMEOUT'})), value_in_json=value_in_json) event_queue = queue.Queue() @@ -71,4 +95,4 @@ class Etcd3Wrapper: class PsuedoEtcdEntry(EtcdEntry): def __init__(self, key, value, value_in_json=False): - super().__init__(PseudoEtcdMeta(key=key.encode("utf-8")), value, value_in_json=value_in_json) + super().__init__(PseudoEtcdMeta(key=key.encode('utf-8')), value, value_in_json=value_in_json) diff --git a/ucloud/common/network.py b/ucloud/common/network.py new file mode 100644 index 0000000..6a6c6e2 --- /dev/null +++ b/ucloud/common/network.py @@ -0,0 +1,60 @@ +import subprocess as sp +import random +import logging +import socket +from contextlib import closing + +logger = logging.getLogger(__name__) + + +def random_bytes(num=6): + return [random.randrange(256) for _ in range(num)] + + +def generate_mac(uaa=False, multicast=False, oui=None, separator=':', byte_fmt='%02x'): + mac = random_bytes() + if oui: + if type(oui) == str: + oui = [int(chunk) for chunk in oui.split(separator)] + mac = oui + random_bytes(num=6 - len(oui)) + else: + if multicast: + mac[0] |= 1 # set bit 0 + else: + mac[0] &= ~1 # clear bit 0 + if uaa: + mac[0] &= ~(1 << 1) # clear bit 1 + else: + mac[0] |= 1 << 1 # set bit 1 + return separator.join(byte_fmt % b for b in mac) + + +def create_dev(script, _id, dev, ip=None): + command = [script, _id, dev] + if ip: + command.append(ip) + try: + output = sp.check_output(command, stderr=sp.PIPE) + except Exception as e: + print(e) + return None + else: + return output.decode('utf-8').strip() + + +def delete_network_interface(iface): + try: + sp.check_output(['ip', 'link', 'del', iface]) + except Exception: + logger.exception('Interface Deletion failed') + + +def find_free_port(): + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: + try: + s.bind(('', 0)) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + except Exception: + return None + else: + return s.getsockname()[1] diff --git a/ucloud/common/storage_handlers.py b/ucloud/common/storage_handlers.py index 8b1097a..d2bd452 100644 --- a/ucloud/common/storage_handlers.py +++ b/ucloud/common/storage_handlers.py @@ -7,6 +7,8 @@ from abc import ABC from . import logger from os.path import join as join_path +from ucloud.settings import settings as config + class ImageStorageHandler(ABC): def __init__(self, image_base, vm_base): @@ -156,3 +158,19 @@ class CEPHBasedImageStorageHandler(ImageStorageHandler): path = join_path(self.vm_base, path) command = ["rbd", "info", path] return self.execute_command(command, report=False) + + +def get_storage_handler(): + __storage_backend = config['storage']['storage_backend'] + if __storage_backend == 'filesystem': + return FileSystemBasedImageStorageHandler( + vm_base=config['storage']['vm_dir'], + image_base=config['storage']['image_dir'] + ) + elif __storage_backend == 'ceph': + return CEPHBasedImageStorageHandler( + vm_base=config['storage']['ceph_vm_pool'], + image_base=config['storage']['ceph_image_pool'] + ) + else: + raise Exception('Unknown Image Storage Handler') diff --git a/ucloud/config.py b/ucloud/config.py deleted file mode 100644 index 97d1561..0000000 --- a/ucloud/config.py +++ /dev/null @@ -1,39 +0,0 @@ -import configparser -import os -import logging - -from ucloud.common.host import HostPool -from ucloud.common.request import RequestPool -from ucloud.common.vm import VmPool -from ucloud.common.storage_handlers import (FileSystemBasedImageStorageHandler, - CEPHBasedImageStorageHandler) -from ucloud.common.etcd_wrapper import Etcd3Wrapper -from ucloud.settings import Settings -from os.path import join as join_path - -logger = logging.getLogger('ucloud.config') - - - -config = Settings() -etcd_client = config.get_etcd_client() - -host_pool = HostPool(etcd_client, config['etcd']['host_prefix']) -vm_pool = VmPool(etcd_client, config['etcd']['vm_prefix']) -request_pool = RequestPool(etcd_client, config['etcd']['request_prefix']) - -running_vms = [] - -__storage_backend = config['storage']['storage_backend'] -if __storage_backend == 'filesystem': - image_storage_handler = FileSystemBasedImageStorageHandler( - vm_base=config['storage']['vm_dir'], - image_base=config['storage']['image_dir'] - ) -elif __storage_backend == 'ceph': - image_storage_handler = CEPHBasedImageStorageHandler( - vm_base=config['storage']['ceph_vm_pool'], - image_base=config['storage']['ceph_image_pool'] - ) -else: - raise Exception('Unknown Image Storage Handler') diff --git a/ucloud/configure/main.py b/ucloud/configure/main.py index 0baa8eb..71e07a1 100644 --- a/ucloud/configure/main.py +++ b/ucloud/configure/main.py @@ -2,21 +2,19 @@ import argparse import sys import os -from ucloud.settings import Settings +from ucloud.settings import settings +from ucloud.shared import shared -config = Settings() -etcd_client = config.get_etcd_client() def update_config(section, kwargs): - uncloud_config = etcd_client.get(config.config_key, - value_in_json=True) + uncloud_config = shared.etcd_client.get(settings.config_key, value_in_json=True) if not uncloud_config: uncloud_config = {} else: uncloud_config = uncloud_config.value uncloud_config[section] = kwargs - etcd_client.put(config.config_key, uncloud_config, value_in_json=True) + shared.etcd_client.put(settings.config_key, uncloud_config, value_in_json=True) def configure_parser(parser): diff --git a/ucloud/filescanner/main.py b/ucloud/filescanner/main.py index 14a77cf..ff38748 100755 --- a/ucloud/filescanner/main.py +++ b/ucloud/filescanner/main.py @@ -8,8 +8,8 @@ import sys from uuid import uuid4 from . import logger -from ucloud.config import config, etcd_client - +from ucloud.settings import settings +from ucloud.shared import shared def getxattr(file, attr): """Get specified user extended attribute (arg:attr) of a file (arg:file)""" @@ -69,11 +69,10 @@ except Exception as e: def main(): - BASE_DIR = config['storage']['file_dir'] - FILE_PREFIX = config['etcd']['file_prefix'] + base_dir = settings['storage']['file_dir'] # Recursively Get All Files and Folder below BASE_DIR - files = glob.glob("{}/**".format(BASE_DIR), recursive=True) + files = glob.glob("{}/**".format(base_dir), recursive=True) # Retain only Files files = list(filter(os.path.isfile, files)) @@ -89,7 +88,7 @@ def main(): file_id = uuid4() # Get Username - owner = pathlib.Path(file).parts[len(pathlib.Path(BASE_DIR).parts)] + owner = pathlib.Path(file).parts[len(pathlib.Path(base_dir).parts)] # Get Creation Date of File # Here, we are assuming that ctime is creation time @@ -105,7 +104,7 @@ def main(): file_path = pathlib.Path(file).parts[-1] # Create Entry - entry_key = os.path.join(FILE_PREFIX, str(file_id)) + entry_key = os.path.join(settings['etcd']['file_prefix'], str(file_id)) entry_value = { "filename": file_path, "owner": owner, @@ -115,8 +114,8 @@ def main(): } logger.info("Tracking %s", file) - # Insert Entry - etcd_client.put(entry_key, entry_value, value_in_json=True) + + shared.etcd_client.put(entry_key, entry_value, value_in_json=True) setxattr(file, "utracked", True) diff --git a/ucloud/host/helper.py b/ucloud/host/helper.py deleted file mode 100644 index edcb82d..0000000 --- a/ucloud/host/helper.py +++ /dev/null @@ -1,13 +0,0 @@ -import socket -from contextlib import closing - - -def find_free_port(): - with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: - try: - s.bind(('', 0)) - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - except Exception: - return None - else: - return s.getsockname()[1] diff --git a/ucloud/host/main.py b/ucloud/host/main.py index ddc52c7..f78f629 100755 --- a/ucloud/host/main.py +++ b/ucloud/host/main.py @@ -3,22 +3,21 @@ import multiprocessing as mp import time import sys -from os.path import isdir -from ucloud.common.etcd_wrapper import Etcd3Wrapper from ucloud.common.request import RequestEntry, RequestType -from ucloud.config import (vm_pool, request_pool, - etcd_client, running_vms, - HostPool, config) +from ucloud.common.host import HostPool +from ucloud.shared import shared +from ucloud.settings import settings -from .helper import find_free_port from . import virtualmachine, logger +vmm = virtualmachine.VMM() + def update_heartbeat(hostname): """Update Last HeartBeat Time for :param hostname: in etcd""" - client = config.get_etcd_client() - host_pool = HostPool(client, config['etcd']['host_prefix']) + client = shared.etcd_client + host_pool = HostPool(client) this_host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) while True: @@ -27,122 +26,55 @@ def update_heartbeat(hostname): time.sleep(10) -def maintenance(host): - # To capture vm running according to running_vms list - - # This is to capture successful migration of a VM. - # Suppose, this host is running "vm1" and user initiated - # request to migrate this "vm1" to some other host. On, - # successful migration the destination host would set - # the vm hostname to itself. Thus, we are checking - # whether this host vm is successfully migrated. If yes - # then we shutdown "vm1" on this host. - - to_be_removed = [] - for running_vm in running_vms: - with vm_pool.get_put(running_vm.key) as vm_entry: - if vm_entry.hostname != host.key and not vm_entry.in_migration: - running_vm.handle.shutdown() - logger.info("VM migration not completed successfully.") - to_be_removed.append(running_vm) - - for r in to_be_removed: - running_vms.remove(r) - - # To check vm running according to etcd entries - alleged_running_vms = vm_pool.by_status("RUNNING", vm_pool.by_host(host.key)) - - for vm_entry in alleged_running_vms: - _vm = virtualmachine.get_vm(running_vms, vm_entry.key) - # Whether, the allegedly running vm is in our - # running_vms list or not if it is said to be - # running on this host but it is not then we - # need to shut it down - - # This is to capture poweroff/shutdown of a VM - # initiated by user inside VM. OR crash of VM by some - # user running process - if (_vm and not _vm.handle.is_running()) or not _vm: - logger.debug("_vm = %s, is_running() = %s" % (_vm, _vm.handle.is_running())) - vm_entry.add_log("""{} is not running but is said to be running. - So, shutting it down and declare it killed""".format(vm_entry.key)) - vm_entry.declare_killed() - vm_pool.put(vm_entry) - if _vm: - running_vms.remove(_vm) - -def check(): - if config['storage']['storage_backend'] == 'filesystem' and \ - not isdir(config['storage']['vm_dir']): - - print("You have set STORAGE_BACKEND to filesystem. So, the vm directory mentioned" - " in /etc/ucloud/ucloud.conf file must exists. But, it don't.") - sys.exit(1) - - def main(hostname): - check() - - heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(hostname,)) - - host_pool = HostPool(etcd_client, config['etcd']['host_prefix']) + host_pool = HostPool(shared.etcd_client) host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) assert host is not None, "No such host with name = {}".format(hostname) try: + heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(hostname,)) heartbeat_updating_process.start() except Exception as e: - logger.info("No Need To Go Further. Our heartbeat updating mechanism is not working") logger.exception(e) - exit(-1) - - logger.info("%s Session Started %s", '*' * 5, '*' * 5) - - # It is seen that under heavy load, timeout event doesn't come - # in a predictive manner (which is intentional because we give - # higher priority to customer's requests) which delays heart - # beat update which in turn misunderstood by scheduler that the - # host is dead when it is actually alive. So, to ensure that we - # update the heart beat in a predictive manner we start Heart - # beat updating mechanism in separated thread + sys.exit("No Need To Go Further. ucloud-host heartbeat updating mechanism is not working") for events_iterator in [ - etcd_client.get_prefix(config['etcd']['request_prefix'], value_in_json=True), - etcd_client.watch_prefix(config['etcd']['request_prefix'], timeout=10, value_in_json=True), + shared.etcd_client.get_prefix(settings['etcd']['request_prefix'], value_in_json=True), + shared.etcd_client.watch_prefix(settings['etcd']['request_prefix'], timeout=10, value_in_json=True), ]: for request_event in events_iterator: request_event = RequestEntry(request_event) if request_event.type == "TIMEOUT": - maintenance(host) + vmm.maintenance(host) continue # If the event is directed toward me OR I am destination of a InitVMMigration if request_event.hostname == host.key or request_event.destination == host.key: logger.debug("VM Request: %s", request_event) - request_pool.client.client.delete(request_event.key) - vm_entry = vm_pool.get(request_event.uuid) + shared.request_pool.client.client.delete(request_event.key) + vm_entry = shared.vm_pool.get(request_event.uuid) if vm_entry: if request_event.type == RequestType.StartVM: - virtualmachine.start(vm_entry) + vmm.start(vm_entry) elif request_event.type == RequestType.StopVM: - virtualmachine.stop(vm_entry) + vmm.stop(vm_entry) elif request_event.type == RequestType.DeleteVM: - virtualmachine.delete(vm_entry) + vmm.delete(vm_entry) elif request_event.type == RequestType.InitVMMigration: - virtualmachine.start(vm_entry, host.key, find_free_port()) + vmm.start(vm_entry, host.key) elif request_event.type == RequestType.TransferVM: - virtualmachine.transfer(request_event) + vmm.transfer(request_event) else: logger.info("VM Entry missing") - logger.info("Running VMs %s", running_vms) + logger.info("Running VMs %s", vmm.running_vms) if __name__ == "__main__": diff --git a/ucloud/host/virtualmachine.py b/ucloud/host/virtualmachine.py index 4a7584a..cc06ce3 100755 --- a/ucloud/host/virtualmachine.py +++ b/ucloud/host/virtualmachine.py @@ -5,7 +5,6 @@ # https://qemu.weilnetz.de/doc/qemu-doc.html#pcsys_005fmonitor import os -import random import subprocess as sp import tempfile import time @@ -21,11 +20,12 @@ import sshtunnel from ucloud.common.helpers import get_ipv6_address from ucloud.common.request import RequestEntry, RequestType from ucloud.common.vm import VMEntry, VMStatus -from ucloud.config import (etcd_client, request_pool, - running_vms, vm_pool, config, - image_storage_handler) -from . import qmp +from ucloud.common.network import create_dev, delete_network_interface, find_free_port from ucloud.host import logger +from ucloud.shared import shared +from ucloud.settings import settings + +from . import qmp class VM: @@ -38,193 +38,22 @@ class VM: return "VM({})".format(self.key) -def delete_network_interface(iface): - try: - sp.check_output(['ip', 'link', 'del', iface]) - except Exception: - pass +def capture_all_exception(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + func(*args, **kwargs) + except Exception: + logger.info("Exception absorbed by captual_all_exception()") + logger.exception(func.__name__) - -def resolve_network(network_name, network_owner): - network = etcd_client.get(join_path(config['etcd']['network_prefix'], - network_owner, - network_name), - value_in_json=True) - return network - - -def delete_vm_network(vm_entry): - try: - for network in vm_entry.network: - network_name = network[0] - tap_mac = network[1] - tap_id = network[2] - - delete_network_interface('tap{}'.format(tap_id)) - - owners_vms = vm_pool.by_owner(vm_entry.owner) - owners_running_vms = vm_pool.by_status(VMStatus.running, - _vms=owners_vms) - - networks = map(lambda n: n[0], - map(lambda vm: vm.network, owners_running_vms) - ) - networks_in_use_by_user_vms = [vm[0] for vm in networks] - if network_name not in networks_in_use_by_user_vms: - network_entry = resolve_network(network[0], vm_entry.owner) - if network_entry: - network_type = network_entry.value["type"] - network_id = network_entry.value["id"] - if network_type == "vxlan": - delete_network_interface('br{}'.format(network_id)) - delete_network_interface('vxlan{}'.format(network_id)) - except Exception: - logger.exception("Exception in network interface deletion") - - -def create_dev(script, _id, dev, ip=None): - command = [script, _id, dev] - if ip: - command.append(ip) - try: - output = sp.check_output(command, stderr=sp.PIPE) - except Exception as e: - print(e.stderr) - return None - else: - return output.decode("utf-8").strip() - - -def create_vxlan_br_tap(_id, _dev, tap_id, ip=None): - network_script_base = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'network') - vxlan = create_dev(script=os.path.join(network_script_base, 'create-vxlan.sh'), - _id=_id, dev=_dev) - if vxlan: - bridge = create_dev(script=os.path.join(network_script_base, 'create-bridge.sh'), - _id=_id, dev=vxlan, ip=ip) - if bridge: - tap = create_dev(script=os.path.join(network_script_base, 'create-tap.sh'), - _id=str(tap_id), dev=bridge) - if tap: - return tap - - -def random_bytes(num=6): - return [random.randrange(256) for _ in range(num)] - - -def generate_mac(uaa=False, multicast=False, oui=None, separator=':', byte_fmt='%02x'): - mac = random_bytes() - if oui: - if type(oui) == str: - oui = [int(chunk) for chunk in oui.split(separator)] - mac = oui + random_bytes(num=6 - len(oui)) - else: - if multicast: - mac[0] |= 1 # set bit 0 - else: - mac[0] &= ~1 # clear bit 0 - if uaa: - mac[0] &= ~(1 << 1) # clear bit 1 - else: - mac[0] |= 1 << 1 # set bit 1 - return separator.join(byte_fmt % b for b in mac) - - -def update_radvd_conf(etcd_client): - network_script_base = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'network') - - networks = { - net.value['ipv6']: net.value['id'] - for net in etcd_client.get_prefix('/v1/network/', value_in_json=True) - if net.value.get('ipv6') - } - radvd_template = open(os.path.join(network_script_base, - 'radvd-template.conf'), 'r').read() - radvd_template = Template(radvd_template) - - content = [radvd_template.safe_substitute(bridge='br{}'.format(networks[net]), - prefix=net) - for net in networks if networks.get(net)] - - with open('/etc/radvd.conf', 'w') as radvd_conf: - radvd_conf.writelines(content) - try: - sp.check_output(['systemctl', 'restart', 'radvd']) - except Exception: - sp.check_output(['service', 'radvd', 'restart']) - - -def get_start_command_args(vm_entry, vnc_sock_filename: str, migration=False, migration_port=None): - threads_per_core = 1 - vm_memory = int(bitmath.parse_string_unsafe(vm_entry.specs["ram"]).to_MB()) - vm_cpus = int(vm_entry.specs["cpu"]) - vm_uuid = vm_entry.uuid - vm_networks = vm_entry.network - - command = "-name {}_{}".format(vm_entry.owner, vm_entry.name) - - command += " -drive file={},format=raw,if=virtio,cache=none".format( - image_storage_handler.qemu_path_string(vm_uuid) - ) - command += " -device virtio-rng-pci -vnc unix:{}".format(vnc_sock_filename) - command += " -m {} -smp cores={},threads={}".format( - vm_memory, vm_cpus, threads_per_core - ) - - if migration: - command += " -incoming tcp:[::]:{}".format(migration_port) - - tap = None - for network_mac_and_tap in vm_networks: - network_name, mac, tap = network_mac_and_tap - - _key = os.path.join(config['etcd']['network_prefix'], vm_entry.owner, network_name) - network = etcd_client.get(_key, value_in_json=True) - network_type = network.value["type"] - network_id = str(network.value["id"]) - network_ipv6 = network.value["ipv6"] - - if network_type == "vxlan": - tap = create_vxlan_br_tap(_id=network_id, - _dev=config['network']['vxlan_phy_dev'], - tap_id=tap, - ip=network_ipv6) - update_radvd_conf(etcd_client) - - command += " -netdev tap,id=vmnet{net_id},ifname={tap},script=no,downscript=no" \ - " -device virtio-net-pci,netdev=vmnet{net_id},mac={mac}" \ - .format(tap=tap, net_id=network_id, mac=mac) - - return command.split(" ") - - -def create_vm_object(vm_entry, migration=False, migration_port=None): - # NOTE: If migration suddenly stop working, having different - # VNC unix filename on source and destination host can - # be a possible cause of it. - - # REQUIREMENT: Use Unix Socket instead of TCP Port for VNC - vnc_sock_file = tempfile.NamedTemporaryFile() - - qemu_args = get_start_command_args( - vm_entry=vm_entry, - vnc_sock_filename=vnc_sock_file.name, - migration=migration, - migration_port=migration_port, - ) - qemu_machine = qmp.QEMUMachine("/usr/bin/qemu-system-x86_64", args=qemu_args) - return VM(vm_entry.key, qemu_machine, vnc_sock_file) - - -def get_vm(vm_list: list, vm_key) -> Union[VM, None]: - return next((vm for vm in vm_list if vm.key == vm_key), None) + return wrapper def need_running_vm(func): @wraps(func) - def wrapper(e): - vm = get_vm(running_vms, e.key) + def wrapper(self, e): + vm = self.get_vm(self.running_vms, e.key) if vm: try: status = vm.handle.command("query-status") @@ -242,143 +71,336 @@ def need_running_vm(func): return wrapper -def create(vm_entry: VMEntry): - if image_storage_handler.is_vm_image_exists(vm_entry.uuid): - # File Already exists. No Problem Continue - logger.debug("Image for vm %s exists", vm_entry.uuid) - else: - vm_hdd = int(bitmath.parse_string_unsafe(vm_entry.specs["os-ssd"]).to_MB()) - if image_storage_handler.make_vm_image(src=vm_entry.image_uuid, dest=vm_entry.uuid): - if not image_storage_handler.resize_vm_image(path=vm_entry.uuid, size=vm_hdd): - vm_entry.status = VMStatus.error - else: - logger.info("New VM Created") +class VMM: + def __init__(self): + self.etcd_client = shared.etcd_client + self.storage_handler = shared.storage_handler + self.running_vms = [] + def get_start_command_args(self, vm_entry, vnc_sock_filename: str, migration=False, migration_port=None): + threads_per_core = 1 + vm_memory = int(bitmath.parse_string_unsafe(vm_entry.specs['ram']).to_MB()) + vm_cpus = int(vm_entry.specs['cpu']) + vm_uuid = vm_entry.uuid + vm_networks = vm_entry.network -def start(vm_entry: VMEntry, destination_host_key=None, migration_port=None): - _vm = get_vm(running_vms, vm_entry.key) + command = '-name {}_{}'.format(vm_entry.owner, vm_entry.name) - # VM already running. No need to proceed further. - if _vm: - logger.info("VM %s already running" % vm_entry.uuid) - return - else: - logger.info("Trying to start %s" % vm_entry.uuid) - if destination_host_key: - launch_vm(vm_entry, migration=True, migration_port=migration_port, - destination_host_key=destination_host_key) - else: - create(vm_entry) - launch_vm(vm_entry) - - -@need_running_vm -def stop(vm_entry): - vm = get_vm(running_vms, vm_entry.key) - vm.handle.shutdown() - if not vm.handle.is_running(): - vm_entry.add_log("Shutdown successfully") - vm_entry.declare_stopped() - vm_pool.put(vm_entry) - running_vms.remove(vm) - delete_vm_network(vm_entry) - - -def delete(vm_entry): - logger.info("Deleting VM | %s", vm_entry) - stop(vm_entry) - - if image_storage_handler.is_vm_image_exists(vm_entry.uuid): - r_status = image_storage_handler.delete_vm_image(vm_entry.uuid) - if r_status: - etcd_client.client.delete(vm_entry.key) - else: - etcd_client.client.delete(vm_entry.key) - -def transfer(request_event): - # This function would run on source host i.e host on which the vm - # is running initially. This host would be responsible for transferring - # vm state to destination host. - - _host, _port = request_event.parameters["host"], request_event.parameters["port"] - _uuid = request_event.uuid - _destination = request_event.destination_host_key - vm = get_vm(running_vms, join_path(config['etcd']['vm_prefix'], _uuid)) - - if vm: - tunnel = sshtunnel.SSHTunnelForwarder( - _host, - ssh_username=config['ssh']['username'], - ssh_pkey=config['ssh']['private_key_path'], - remote_bind_address=("127.0.0.1", _port), - ssh_proxy_enabled=True, - ssh_proxy=(_host, 22) + command += ' -drive file={},format=raw,if=virtio,cache=none'.format( + self.storage_handler.qemu_path_string(vm_uuid) ) - try: - tunnel.start() - except sshtunnel.BaseSSHTunnelForwarderError: - logger.exception("Couldn't establish connection to (%s, 22)", _host) + command += ' -device virtio-rng-pci -vnc unix:{}'.format(vnc_sock_filename) + command += ' -m {} -smp cores={},threads={}'.format( + vm_memory, vm_cpus, threads_per_core + ) + + if migration: + command += ' -incoming tcp:[::]:{}'.format(migration_port) + + for network_mac_and_tap in vm_networks: + network_name, mac, tap = network_mac_and_tap + + _key = os.path.join(settings['etcd']['network_prefix'], vm_entry.owner, network_name) + network = self.etcd_client.get(_key, value_in_json=True) + network_type = network.value["type"] + network_id = str(network.value["id"]) + network_ipv6 = network.value["ipv6"] + + if network_type == "vxlan": + tap = create_vxlan_br_tap(_id=network_id, + _dev=settings['network']['vxlan_phy_dev'], + tap_id=tap, + ip=network_ipv6) + all_networks = self.etcd_client.get_prefix('/v1/network/', value_in_json=True) + update_radvd_conf(all_networks) + + command += " -netdev tap,id=vmnet{net_id},ifname={tap},script=no,downscript=no" \ + " -device virtio-net-pci,netdev=vmnet{net_id},mac={mac}" \ + .format(tap=tap, net_id=network_id, mac=mac) + + return command.split(" ") + + def create_vm_object(self, vm_entry, migration=False, migration_port=None): + vnc_sock_file = tempfile.NamedTemporaryFile() + + qemu_args = self.get_start_command_args( + vm_entry=vm_entry, + vnc_sock_filename=vnc_sock_file.name, + migration=migration, + migration_port=migration_port, + ) + qemu_machine = qmp.QEMUMachine("/usr/bin/qemu-system-x86_64", args=qemu_args) + return VM(vm_entry.key, qemu_machine, vnc_sock_file) + + @staticmethod + def get_vm(vm_list: list, vm_key) -> Union[VM, None]: + return next((vm for vm in vm_list if vm.key == vm_key), None) + + @capture_all_exception + def create(self, vm_entry: VMEntry): + if self.storage_handler.is_vm_image_exists(vm_entry.uuid): + # File Already exists. No Problem Continue + logger.debug("Image for vm %s exists", vm_entry.uuid) + return None else: - vm.handle.command( - "migrate", uri="tcp:0.0.0.0:{}".format(tunnel.local_bind_port) - ) + vm_hdd = int(bitmath.parse_string_unsafe(vm_entry.specs["os-ssd"]).to_MB()) + if self.storage_handler.make_vm_image(src=vm_entry.image_uuid, dest=vm_entry.uuid): + if not self.storage_handler.resize_vm_image(path=vm_entry.uuid, size=vm_hdd): + vm_entry.status = VMStatus.error + else: + logger.info("New VM Created") - status = vm.handle.command("query-migrate")["status"] - while status not in ["failed", "completed"]: - time.sleep(2) - status = vm.handle.command("query-migrate")["status"] + @capture_all_exception + def start(self, vm_entry: VMEntry, destination_host_key=None): + _vm = self.get_vm(self.running_vms, vm_entry.key) - with vm_pool.get_put(request_event.uuid) as source_vm: - if status == "failed": - source_vm.add_log("Migration Failed") - elif status == "completed": - # If VM is successfully migrated then shutdown the VM - # on this host and update hostname to destination host key - source_vm.add_log("Successfully migrated") - source_vm.hostname = _destination - running_vms.remove(vm) - vm.handle.shutdown() - source_vm.in_migration = False # VM transfer finished - finally: - tunnel.close() + # VM already running. No need to proceed further. + if _vm: + logger.info("VM %s already running" % vm_entry.uuid) + return + else: + logger.info("Trying to start %s" % vm_entry.uuid) + if destination_host_key: + migration_port = find_free_port() + self.launch_vm(vm_entry, migration=True, migration_port=migration_port, + destination_host_key=destination_host_key) + else: + self.create(vm_entry) + self.launch_vm(vm_entry) - -def launch_vm(vm_entry, migration=False, migration_port=None, destination_host_key=None): - logger.info("Starting %s" % vm_entry.key) - - vm = create_vm_object(vm_entry, migration=migration, migration_port=migration_port) - try: - vm.handle.launch() - except Exception: - logger.exception("Error Occured while starting VM") + @need_running_vm + @capture_all_exception + def stop(self, vm_entry): + vm = self.get_vm(self.running_vms, vm_entry.key) vm.handle.shutdown() + if not vm.handle.is_running(): + vm_entry.add_log("Shutdown successfully") + vm_entry.declare_stopped() + shared.vm_pool.put(vm_entry) + self.running_vms.remove(vm) + delete_vm_network(vm_entry) - if migration: - # We don't care whether MachineError or any other error occurred - pass + @capture_all_exception + def delete(self, vm_entry): + logger.info("Deleting VM | %s", vm_entry) + self.stop(vm_entry) + + if self.storage_handler.is_vm_image_exists(vm_entry.uuid): + r_status = self.storage_handler.delete_vm_image(vm_entry.uuid) + if r_status: + shared.etcd_client.client.delete(vm_entry.key) else: - # Error during typical launch of a vm - vm.handle.shutdown() - vm_entry.declare_killed() - vm_pool.put(vm_entry) - else: - vm_entry.vnc_socket = vm.vnc_socket_file.name - running_vms.append(vm) + shared.etcd_client.client.delete(vm_entry.key) - if migration: - vm_entry.in_migration = True - r = RequestEntry.from_scratch( - type=RequestType.TransferVM, - hostname=vm_entry.hostname, - parameters={"host": get_ipv6_address(), "port": migration_port}, - uuid=vm_entry.uuid, - destination_host_key=destination_host_key, - request_prefix=config['etcd']['request_prefix'] + @capture_all_exception + def transfer(self, request_event): + # This function would run on source host i.e host on which the vm + # is running initially. This host would be responsible for transferring + # vm state to destination host. + + _host, _port = request_event.parameters["host"], request_event.parameters["port"] + _uuid = request_event.uuid + _destination = request_event.destination_host_key + vm = self.get_vm(self.running_vms, join_path(settings['etcd']['vm_prefix'], _uuid)) + + if vm: + tunnel = sshtunnel.SSHTunnelForwarder( + _host, + ssh_username=settings['ssh']['username'], + ssh_pkey=settings['ssh']['private_key_path'], + remote_bind_address=("127.0.0.1", _port), + ssh_proxy_enabled=True, + ssh_proxy=(_host, 22) ) - request_pool.put(r) - else: - # Typical launching of a vm - vm_entry.status = VMStatus.running - vm_entry.add_log("Started successfully") + try: + tunnel.start() + except sshtunnel.BaseSSHTunnelForwarderError: + logger.exception("Couldn't establish connection to (%s, 22)", _host) + else: + vm.handle.command( + "migrate", uri="tcp:0.0.0.0:{}".format(tunnel.local_bind_port) + ) - vm_pool.put(vm_entry) + status = vm.handle.command("query-migrate")["status"] + while status not in ["failed", "completed"]: + time.sleep(2) + status = vm.handle.command("query-migrate")["status"] + + with shared.vm_pool.get_put(request_event.uuid) as source_vm: + if status == "failed": + source_vm.add_log("Migration Failed") + elif status == "completed": + # If VM is successfully migrated then shutdown the VM + # on this host and update hostname to destination host key + source_vm.add_log("Successfully migrated") + source_vm.hostname = _destination + self.running_vms.remove(vm) + vm.handle.shutdown() + source_vm.in_migration = False # VM transfer finished + finally: + tunnel.close() + + @capture_all_exception + def launch_vm(self, vm_entry, migration=False, migration_port=None, destination_host_key=None): + logger.info("Starting %s" % vm_entry.key) + + vm = self.create_vm_object(vm_entry, migration=migration, migration_port=migration_port) + try: + vm.handle.launch() + except Exception: + logger.exception("Error Occured while starting VM") + vm.handle.shutdown() + + if migration: + # We don't care whether MachineError or any other error occurred + pass + else: + # Error during typical launch of a vm + vm.handle.shutdown() + vm_entry.declare_killed() + shared.vm_pool.put(vm_entry) + else: + vm_entry.vnc_socket = vm.vnc_socket_file.name + self.running_vms.append(vm) + + if migration: + vm_entry.in_migration = True + r = RequestEntry.from_scratch( + type=RequestType.TransferVM, + hostname=vm_entry.hostname, + parameters={"host": get_ipv6_address(), "port": migration_port}, + uuid=vm_entry.uuid, + destination_host_key=destination_host_key, + request_prefix=settings['etcd']['request_prefix'] + ) + shared.request_pool.put(r) + else: + # Typical launching of a vm + vm_entry.status = VMStatus.running + vm_entry.add_log("Started successfully") + + shared.vm_pool.put(vm_entry) + + @capture_all_exception + def maintenance(self, host): + # To capture vm running according to running_vms list + + # This is to capture successful migration of a VM. + # Suppose, this host is running "vm1" and user initiated + # request to migrate this "vm1" to some other host. On, + # successful migration the destination host would set + # the vm hostname to itself. Thus, we are checking + # whether this host vm is successfully migrated. If yes + # then we shutdown "vm1" on this host. + logger.debug("Starting Maintenance!!") + to_be_removed = [] + for running_vm in self.running_vms: + with shared.vm_pool.get_put(running_vm.key) as vm_entry: + if vm_entry.hostname != host.key and not vm_entry.in_migration: + running_vm.handle.shutdown() + logger.info("VM migration not completed successfully.") + to_be_removed.append(running_vm) + + for r in to_be_removed: + self.running_vms.remove(r) + + # To check vm running according to etcd entries + alleged_running_vms = shared.vm_pool.by_status("RUNNING", shared.vm_pool.by_host(host.key)) + + for vm_entry in alleged_running_vms: + _vm = self.get_vm(self.running_vms, vm_entry.key) + # Whether, the allegedly running vm is in our + # running_vms list or not if it is said to be + # running on this host but it is not then we + # need to shut it down + + # This is to capture poweroff/shutdown of a VM + # initiated by user inside VM. OR crash of VM by some + # user running process + if (_vm and not _vm.handle.is_running()) or not _vm: + logger.debug("_vm = %s, is_running() = %s" % (_vm, _vm.handle.is_running())) + vm_entry.add_log("""{} is not running but is said to be running. + So, shutting it down and declare it killed""".format(vm_entry.key)) + vm_entry.declare_killed() + shared.vm_pool.put(vm_entry) + if _vm: + self.running_vms.remove(_vm) + + +def resolve_network(network_name, network_owner): + network = shared.etcd_client.get(join_path(settings['etcd']['network_prefix'], + network_owner, + network_name), + value_in_json=True) + return network + + +def delete_vm_network(vm_entry): + try: + for network in vm_entry.network: + network_name = network[0] + tap_mac = network[1] + tap_id = network[2] + + delete_network_interface('tap{}'.format(tap_id)) + + owners_vms = shared.vm_pool.by_owner(vm_entry.owner) + owners_running_vms = shared.vm_pool.by_status(VMStatus.running, + _vms=owners_vms) + + networks = map( + lambda n: n[0], map(lambda vm: vm.network, owners_running_vms) + ) + networks_in_use_by_user_vms = [vm[0] for vm in networks] + if network_name not in networks_in_use_by_user_vms: + network_entry = resolve_network(network[0], vm_entry.owner) + if network_entry: + network_type = network_entry.value["type"] + network_id = network_entry.value["id"] + if network_type == "vxlan": + delete_network_interface('br{}'.format(network_id)) + delete_network_interface('vxlan{}'.format(network_id)) + except Exception: + logger.exception("Exception in network interface deletion") + + +def create_vxlan_br_tap(_id, _dev, tap_id, ip=None): + network_script_base = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'network') + vxlan = create_dev(script=os.path.join(network_script_base, 'create-vxlan.sh'), + _id=_id, dev=_dev) + if vxlan: + bridge = create_dev(script=os.path.join(network_script_base, 'create-bridge.sh'), + _id=_id, dev=vxlan, ip=ip) + if bridge: + tap = create_dev(script=os.path.join(network_script_base, 'create-tap.sh'), + _id=str(tap_id), dev=bridge) + if tap: + return tap + + +def update_radvd_conf(all_networks): + network_script_base = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'network') + + networks = { + net.value['ipv6']: net.value['id'] + for net in all_networks + if net.value.get('ipv6') + } + radvd_template = open(os.path.join(network_script_base, + 'radvd-template.conf'), 'r').read() + radvd_template = Template(radvd_template) + + content = [ + radvd_template.safe_substitute( + bridge='br{}'.format(networks[net]), + prefix=net + ) + for net in networks if networks.get(net) + ] + + with open('/etc/radvd.conf', 'w') as radvd_conf: + radvd_conf.writelines(content) + try: + sp.check_output(['systemctl', 'restart', 'radvd']) + except Exception: + sp.check_output(['service', 'radvd', 'restart']) diff --git a/ucloud/imagescanner/main.py b/ucloud/imagescanner/main.py index 135f8cb..d164ea3 100755 --- a/ucloud/imagescanner/main.py +++ b/ucloud/imagescanner/main.py @@ -5,7 +5,8 @@ import sys from os.path import isdir from os.path import join as join_path -from ucloud.config import etcd_client, config, image_storage_handler +from ucloud.settings import settings +from ucloud.shared import shared from ucloud.imagescanner import logger @@ -22,9 +23,9 @@ def qemu_img_type(path): def check(): """ check whether settings are sane, refuse to start if they aren't """ - if config['storage']['storage_backend'] == 'filesystem' and not isdir(config['storage']['image_dir']): + if settings['storage']['storage_backend'] == 'filesystem' and not isdir(settings['storage']['image_dir']): sys.exit("You have set STORAGE_BACKEND to filesystem, but " - "{} does not exist. Refusing to start".format(config['storage']['image_dir']) + "{} does not exist. Refusing to start".format(settings['storage']['image_dir']) ) try: @@ -36,7 +37,7 @@ def check(): def main(): # We want to get images entries that requests images to be created - images = etcd_client.get_prefix(config['etcd']['image_prefix'], value_in_json=True) + images = shared.etcd_client.get_prefix(settings['etcd']['image_prefix'], value_in_json=True) images_to_be_created = list(filter(lambda im: im.value['status'] == 'TO_BE_CREATED', images)) for image in images_to_be_created: @@ -45,9 +46,9 @@ def main(): image_owner = image.value['owner'] image_filename = image.value['filename'] image_store_name = image.value['store_name'] - image_full_path = join_path(config['storage']['file_dir'], image_owner, image_filename) + image_full_path = join_path(settings['storage']['file_dir'], image_owner, image_filename) - image_stores = etcd_client.get_prefix(config['etcd']['image_store_prefix'], + image_stores = shared.etcd_client.get_prefix(settings['etcd']['image_store_prefix'], value_in_json=True) user_image_store = next(filter( lambda s, store_name=image_store_name: s.value["name"] == store_name, @@ -71,18 +72,18 @@ def main(): logger.exception(e) else: # Import and Protect - r_status = image_storage_handler.import_image(src="image.raw", + r_status = shared.storage_handler.import_image(src="image.raw", dest=image_uuid, protect=True) if r_status: # Everything is successfully done image.value["status"] = "CREATED" - etcd_client.put(image.key, json.dumps(image.value)) + shared.etcd_client.put(image.key, json.dumps(image.value)) else: # The user provided image is either not found or of invalid format image.value["status"] = "INVALID_IMAGE" - etcd_client.put(image.key, json.dumps(image.value)) + shared.etcd_client.put(image.key, json.dumps(image.value)) try: os.remove("image.raw") diff --git a/ucloud/metadata/main.py b/ucloud/metadata/main.py index 16b7c6d..5526084 100644 --- a/ucloud/metadata/main.py +++ b/ucloud/metadata/main.py @@ -2,15 +2,15 @@ import os from flask import Flask, request from flask_restful import Resource, Api - -from ucloud.config import etcd_client, config, vm_pool +from ucloud.settings import settings +from ucloud.shared import shared app = Flask(__name__) api = Api(app) def get_vm_entry(mac_addr): - return next(filter(lambda vm: mac_addr in list(zip(*vm.network))[1], vm_pool.vms), None) + return next(filter(lambda vm: mac_addr in list(zip(*vm.network))[1], shared.vm_pool.vms), None) # https://stackoverflow.com/questions/37140846/how-to-convert-ipv6-link-local-address-to-mac-address-in-python @@ -43,10 +43,10 @@ class Root(Resource): if not data: return {'message': 'Metadata for such VM does not exists.'}, 404 else: - etcd_key = os.path.join(config['etcd']['user_prefix'], + etcd_key = os.path.join(settings['etcd']['user_prefix'], data.value['owner_realm'], data.value['owner'], 'key') - etcd_entry = etcd_client.get_prefix(etcd_key, value_in_json=True) + etcd_entry = shared.etcd_client.get_prefix(etcd_key, value_in_json=True) user_personal_ssh_keys = [key.value for key in etcd_entry] data.value['metadata']['ssh-keys'] += user_personal_ssh_keys return data.value['metadata'], 200 diff --git a/ucloud/scheduler/helper.py b/ucloud/scheduler/helper.py index 560bdbc..643e8e9 100755 --- a/ucloud/scheduler/helper.py +++ b/ucloud/scheduler/helper.py @@ -6,7 +6,8 @@ import bitmath from ucloud.common.host import HostStatus from ucloud.common.request import RequestEntry, RequestType from ucloud.common.vm import VMStatus -from ucloud.config import vm_pool, host_pool, request_pool, config +from ucloud.shared import shared +from ucloud.settings import settings def accumulated_specs(vms_specs): @@ -46,14 +47,14 @@ class NoSuitableHostFound(Exception): def get_suitable_host(vm_specs, hosts=None): if hosts is None: - hosts = host_pool.by_status(HostStatus.alive) + hosts = shared.host_pool.by_status(HostStatus.alive) for host in hosts: # Filter them by host_name - vms = vm_pool.by_host(host.key) + vms = shared.vm_pool.by_host(host.key) # Filter them by status - vms = vm_pool.by_status(VMStatus.running, vms) + vms = shared.vm_pool.by_status(VMStatus.running, vms) running_vms_specs = [vm.specs for vm in vms] @@ -75,7 +76,7 @@ def get_suitable_host(vm_specs, hosts=None): def dead_host_detection(): # Bring out your dead! - Monty Python and the Holy Grail - hosts = host_pool.by_status(HostStatus.alive) + hosts = shared.host_pool.by_status(HostStatus.alive) dead_hosts_keys = [] for host in hosts: @@ -89,25 +90,25 @@ def dead_host_detection(): def dead_host_mitigation(dead_hosts_keys): for host_key in dead_hosts_keys: - host = host_pool.get(host_key) + host = shared.host_pool.get(host_key) host.declare_dead() - vms_hosted_on_dead_host = vm_pool.by_host(host_key) + vms_hosted_on_dead_host = shared.vm_pool.by_host(host_key) for vm in vms_hosted_on_dead_host: vm.declare_killed() - vm_pool.put(vm) - host_pool.put(host) + shared.vm_pool.put(vm) + shared.host_pool.put(host) def assign_host(vm): vm.hostname = get_suitable_host(vm.specs) - vm_pool.put(vm) + shared.vm_pool.put(vm) r = RequestEntry.from_scratch(type=RequestType.StartVM, uuid=vm.uuid, hostname=vm.hostname, - request_prefix=config['etcd']['request_prefix']) - request_pool.put(r) + request_prefix=settings['etcd']['request_prefix']) + shared.request_pool.put(r) vm.log.append("VM scheduled for starting") return vm.hostname diff --git a/ucloud/scheduler/main.py b/ucloud/scheduler/main.py index 49d6291..3412545 100755 --- a/ucloud/scheduler/main.py +++ b/ucloud/scheduler/main.py @@ -5,8 +5,8 @@ # maybe expose a prometheus compatible output from ucloud.common.request import RequestEntry, RequestType -from ucloud.config import etcd_client -from ucloud.config import host_pool, request_pool, vm_pool, config +from ucloud.shared import shared +from ucloud.settings import settings from .helper import (get_suitable_host, dead_host_mitigation, dead_host_detection, assign_host, NoSuitableHostFound) from . import logger @@ -16,8 +16,8 @@ def main(): pending_vms = [] for request_iterator in [ - etcd_client.get_prefix(config['etcd']['request_prefix'], value_in_json=True), - etcd_client.watch_prefix(config['etcd']['request_prefix'], timeout=5, value_in_json=True), + shared.etcd_client.get_prefix(settings['etcd']['request_prefix'], value_in_json=True), + shared.etcd_client.watch_prefix(settings['etcd']['request_prefix'], timeout=5, value_in_json=True), ]: for request_event in request_iterator: request_entry = RequestEntry(request_event) @@ -44,17 +44,17 @@ def main(): r = RequestEntry.from_scratch(type="ScheduleVM", uuid=pending_vm_entry.uuid, hostname=pending_vm_entry.hostname, - request_prefix=config['etcd']['request_prefix']) - request_pool.put(r) + request_prefix=settings['etcd']['request_prefix']) + shared.request_pool.put(r) elif request_entry.type == RequestType.ScheduleVM: logger.debug("%s, %s", request_entry.key, request_entry.value) - vm_entry = vm_pool.get(request_entry.uuid) + vm_entry = shared.vm_pool.get(request_entry.uuid) if vm_entry is None: logger.info("Trying to act on {} but it is deleted".format(request_entry.uuid)) continue - etcd_client.client.delete(request_entry.key) # consume Request + shared.etcd_client.client.delete(request_entry.key) # consume Request # If the Request is about a VM which is labelled as "migration" # and has a destination @@ -62,7 +62,7 @@ def main(): and hasattr(request_entry, "destination") and request_entry.destination: try: get_suitable_host(vm_specs=vm_entry.specs, - hosts=[host_pool.get(request_entry.destination)]) + hosts=[shared.host_pool.get(request_entry.destination)]) except NoSuitableHostFound: logger.info("Requested destination host doesn't have enough capacity" "to hold %s" % vm_entry.uuid) @@ -70,8 +70,8 @@ def main(): r = RequestEntry.from_scratch(type=RequestType.InitVMMigration, uuid=request_entry.uuid, destination=request_entry.destination, - request_prefix=config['etcd']['request_prefix']) - request_pool.put(r) + request_prefix=settings['etcd']['request_prefix']) + shared.request_pool.put(r) # If the Request is about a VM that just want to get started/created else: @@ -81,7 +81,7 @@ def main(): assign_host(vm_entry) except NoSuitableHostFound: vm_entry.add_log("Can't schedule VM. No Resource Left.") - vm_pool.put(vm_entry) + shared.vm_pool.put(vm_entry) pending_vms.append(vm_entry) logger.info("No Resource Left. Emailing admin....") diff --git a/ucloud/scheduler/main.py.old b/ucloud/scheduler/main.py.old deleted file mode 100755 index e2c975a..0000000 --- a/ucloud/scheduler/main.py.old +++ /dev/null @@ -1,93 +0,0 @@ -# TODO -# 1. send an email to an email address defined by env['admin-email'] -# if resources are finished -# 2. Introduce a status endpoint of the scheduler - -# maybe expose a prometheus compatible output - -from ucloud.common.request import RequestEntry, RequestType -from ucloud.config import etcd_client -from ucloud.config import host_pool, request_pool, vm_pool, env_vars -from .helper import (get_suitable_host, dead_host_mitigation, dead_host_detection, - assign_host, NoSuitableHostFound) -from . import logger - - -def main(): - logger.info("%s SESSION STARTED %s", '*' * 5, '*' * 5) - - pending_vms = [] - - for request_iterator in [ - etcd_client.get_prefix(env_vars.get('REQUEST_PREFIX'), value_in_json=True), - etcd_client.watch_prefix(env_vars.get('REQUEST_PREFIX'), timeout=5, value_in_json=True), - ]: - for request_event in request_iterator: - request_entry = RequestEntry(request_event) - # Never Run time critical mechanism inside timeout - # mechanism because timeout mechanism only comes - # when no other event is happening. It means under - # heavy load there would not be a timeout event. - if request_entry.type == "TIMEOUT": - - # Detect hosts that are dead and set their status - # to "DEAD", and their VMs' status to "KILLED" - dead_hosts = dead_host_detection() - if dead_hosts: - logger.debug("Dead hosts: %s", dead_hosts) - dead_host_mitigation(dead_hosts) - - # If there are VMs that weren't assigned a host - # because there wasn't a host available which - # meets requirement of that VM then we would - # create a new ScheduleVM request for that VM - # on our behalf. - while pending_vms: - pending_vm_entry = pending_vms.pop() - r = RequestEntry.from_scratch(type="ScheduleVM", - uuid=pending_vm_entry.uuid, - hostname=pending_vm_entry.hostname, - request_prefix=env_vars.get("REQUEST_PREFIX")) - request_pool.put(r) - - elif request_entry.type == RequestType.ScheduleVM: - logger.debug("%s, %s", request_entry.key, request_entry.value) - - vm_entry = vm_pool.get(request_entry.uuid) - if vm_entry is None: - logger.info("Trying to act on {} but it is deleted".format(request_entry.uuid)) - continue - etcd_client.client.delete(request_entry.key) # consume Request - - # If the Request is about a VM which is labelled as "migration" - # and has a destination - if hasattr(request_entry, "migration") and request_entry.migration \ - and hasattr(request_entry, "destination") and request_entry.destination: - try: - get_suitable_host(vm_specs=vm_entry.specs, - hosts=[host_pool.get(request_entry.destination)]) - except NoSuitableHostFound: - logger.info("Requested destination host doesn't have enough capacity" - "to hold %s" % vm_entry.uuid) - else: - r = RequestEntry.from_scratch(type=RequestType.InitVMMigration, - uuid=request_entry.uuid, - destination=request_entry.destination, - request_prefix=env_vars.get("REQUEST_PREFIX")) - request_pool.put(r) - - # If the Request is about a VM that just want to get started/created - else: - # assign_host only returns None when we couldn't be able to assign - # a host to a VM because of resource constraints - try: - assign_host(vm_entry) - except NoSuitableHostFound: - vm_entry.add_log("Can't schedule VM. No Resource Left.") - vm_pool.put(vm_entry) - - pending_vms.append(vm_entry) - logger.info("No Resource Left. Emailing admin....") - - -if __name__ == "__main__": - main() diff --git a/ucloud/settings/__init__.py b/ucloud/settings/__init__.py index 5f29c41..2c77300 100644 --- a/ucloud/settings/__init__.py +++ b/ucloud/settings/__init__.py @@ -23,28 +23,28 @@ class CustomConfigParser(configparser.RawConfigParser): class Settings(object): def __init__(self, config_key='/uncloud/config/'): conf_name = 'ucloud.conf' - conf_dir = os.environ.get('UCLOUD_CONF_DIR', '/etc/ucloud') - config_file = os.path.join(conf_dir, conf_name) + conf_dir = os.environ.get('UCLOUD_CONF_DIR', os.path.expanduser('~/ucloud/')) + self.config_file = os.path.join(conf_dir, conf_name) self.config_parser = CustomConfigParser(allow_no_value=True) self.config_key = config_key self.read_internal_values() - self.read_config_file_values(config_file) + self.config_parser.read(self.config_file) - self.etcd_wrapper_args = tuple() - self.etcd_wrapper_kwargs = { - 'host': self.config_parser['etcd']['url'], - 'port': self.config_parser['etcd']['port'], - 'ca_cert': self.config_parser['etcd']['ca_cert'], - 'cert_cert': self.config_parser['etcd']['cert_cert'], - 'cert_key': self.config_parser['etcd']['cert_key'] - } - - def get_etcd_client(self): - args = self.etcd_wrapper_args - kwargs = self.etcd_wrapper_kwargs + args = tuple() + try: + kwargs = { + 'host': self.config_parser.get('etcd', 'url'), + 'port': self.config_parser.get('etcd', 'port'), + 'ca_cert': self.config_parser.get('etcd', 'ca_cert'), + 'cert_cert': self.config_parser.get('etcd','cert_cert'), + 'cert_key': self.config_parser.get('etcd','cert_key') + } + except configparser.Error as err: + raise configparser.Error('{} in config file {}'.format(err.message, self.config_file)) from err + return Etcd3Wrapper(*args, **kwargs) def read_internal_values(self): @@ -78,9 +78,11 @@ class Settings(object): if config_from_etcd: self.config_parser.read_dict(config_from_etcd.value) else: - return - sys.exit("No settings found in etcd at key {}".format(self.config_key)) - + raise KeyError("Key '{}' not found in etcd".format(self.config_key)) + def __getitem__(self, key): self.read_values_from_etcd() return self.config_parser[key] + + +settings = Settings() diff --git a/ucloud/shared/__init__.py b/ucloud/shared/__init__.py new file mode 100644 index 0000000..7a296e9 --- /dev/null +++ b/ucloud/shared/__init__.py @@ -0,0 +1,30 @@ +from ucloud.settings import settings +from ucloud.common.vm import VmPool +from ucloud.common.host import HostPool +from ucloud.common.request import RequestPool +from ucloud.common.storage_handlers import get_storage_handler + + +class Shared: + @property + def etcd_client(self): + return settings.get_etcd_client() + + @property + def host_pool(self): + return HostPool(self.etcd_client, settings['etcd']['host_prefix']) + + @property + def vm_pool(self): + return VmPool(self.etcd_client, settings['etcd']['vm_prefix']) + + @property + def request_pool(self): + return RequestPool(self.etcd_client, settings['etcd']['request_prefix']) + + @property + def storage_handler(self): + return get_storage_handler() + + +shared = Shared() From 88b4d34e1a7d2da47229303ce501a9b899a7cfe8 Mon Sep 17 00:00:00 2001 From: meow Date: Sun, 22 Dec 2019 12:33:59 +0500 Subject: [PATCH 079/284] workaround of setuptools bug that fails to install Flask without version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e273d68..5a624db 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ setup(name='ucloud', packages=find_packages(), install_requires=[ 'requests', - 'Flask', + 'Flask>=1.1.1', 'flask-restful', 'bitmath', 'pyotp', From e4d2c98fb5822e510f6bb979a807765e95d8d6ec Mon Sep 17 00:00:00 2001 From: meow Date: Sun, 22 Dec 2019 13:14:42 +0500 Subject: [PATCH 080/284] Better logging. Errors without stacktrace are now printed to stderr --- scripts/ucloud | 47 +++++++++++++++++++++++-------- ucloud/common/etcd_wrapper.py | 1 - ucloud/common/network.py | 1 + ucloud/common/storage_handlers.py | 1 - 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/scripts/ucloud b/scripts/ucloud index 7f3ef3a..f741663 100755 --- a/scripts/ucloud +++ b/scripts/ucloud @@ -6,6 +6,8 @@ import importlib import multiprocessing as mp import sys +from logging.handlers import SysLogHandler + from ucloud.configure.main import configure_parser @@ -14,10 +16,20 @@ def exception_hook(exc_type, exc_value, exc_traceback): 'Uncaught exception', exc_info=(exc_type, exc_value, exc_traceback) ) - print('Error: ', end='') - print(exc_type, exc_value, exc_traceback) + # print('Error: ', end='') + # print(exc_type, exc_value, exc_traceback) +class NoTracebackStreamHandler(logging.StreamHandler): + def handle(self, record): + info, cache = record.exc_info, record.exc_text + record.exc_info, record.exc_text = None, None + try: + super().handle(record) + finally: + record.exc_info = info + record.exc_text = cache + if __name__ == '__main__': sys.excepthook = exception_hook @@ -44,12 +56,26 @@ if __name__ == '__main__': if not args.command: arg_parser.print_help() else: - logging.basicConfig( - level=logging.DEBUG, - format='%(pathname)s:%(lineno)d -- %(levelname)-8s %(message)s', - handlers=[logging.handlers.SysLogHandler(address = '/dev/log')] - ) - logger = logging.getLogger("ucloud") + # Setting up root logger + logger = logging.getLogger('ucloud') + + syslog_handler = SysLogHandler(address='/dev/log') + syslog_handler.setLevel(logging.DEBUG) + syslog_formatter = logging.Formatter('%(pathname)s:%(lineno)d -- %(levelname)-8s %(message)s') + syslog_handler.setFormatter(syslog_formatter) + + stream_handler = NoTracebackStreamHandler() + stream_handler.setLevel(logging.WARNING) + stream_formatter = logging.Formatter('%(message)s') + stream_handler.setFormatter(stream_formatter) + + logger.addHandler(syslog_handler) + logger.addHandler(stream_handler) + + # if we start etcd in seperate process with default settings + # i.e inheriting few things from parent process etcd3 module + # errors out, so the following command configure multiprocessing + # module to not inherit anything from parent. mp.set_start_method('spawn') arguments = vars(args) @@ -58,6 +84,5 @@ if __name__ == '__main__': mod = importlib.import_module("ucloud.{}.main".format(name)) main = getattr(mod, "main") main(**arguments) - except Exception as e: - logger.exception('Error') - print(e) \ No newline at end of file + except Exception as err: + logger.exception(err) diff --git a/ucloud/common/etcd_wrapper.py b/ucloud/common/etcd_wrapper.py index e249e6c..91149b8 100644 --- a/ucloud/common/etcd_wrapper.py +++ b/ucloud/common/etcd_wrapper.py @@ -33,7 +33,6 @@ def readable_errors(func): except etcd3.exceptions.ConnectionTimeoutError as err: raise etcd3.exceptions.ConnectionTimeoutError('etcd connection timeout') from err except Exception: - print('Some error occurred, most probably it is etcd that is erroring out.') logger.exception('Some etcd error occurred') return wrapper diff --git a/ucloud/common/network.py b/ucloud/common/network.py index 6a6c6e2..df7151b 100644 --- a/ucloud/common/network.py +++ b/ucloud/common/network.py @@ -36,6 +36,7 @@ def create_dev(script, _id, dev, ip=None): try: output = sp.check_output(command, stderr=sp.PIPE) except Exception as e: + logger.exception('Creation of interface %s failed.', dev) print(e) return None else: diff --git a/ucloud/common/storage_handlers.py b/ucloud/common/storage_handlers.py index d2bd452..eaad1a5 100644 --- a/ucloud/common/storage_handlers.py +++ b/ucloud/common/storage_handlers.py @@ -51,7 +51,6 @@ class ImageStorageHandler(ABC): output = sp.check_output(command, stderr=sp.PIPE) except Exception as e: if report: - print(e) logger.exception(e) return False return True From eea6c1568e2ad9675032c3950902ead88bbeb5fc Mon Sep 17 00:00:00 2001 From: meow Date: Sun, 22 Dec 2019 13:47:16 +0500 Subject: [PATCH 081/284] colored error output --- scripts/ucloud | 11 +++++++++++ setup.py | 1 + 2 files changed, 12 insertions(+) diff --git a/scripts/ucloud b/scripts/ucloud index f741663..0a6c5ec 100755 --- a/scripts/ucloud +++ b/scripts/ucloud @@ -6,6 +6,8 @@ import importlib import multiprocessing as mp import sys +import colorama + from logging.handlers import SysLogHandler from ucloud.configure.main import configure_parser @@ -24,11 +26,20 @@ class NoTracebackStreamHandler(logging.StreamHandler): def handle(self, record): info, cache = record.exc_info, record.exc_text record.exc_info, record.exc_text = None, None + + if record.levelname == 'WARNING': + color = colorama.Fore.YELLOW + elif record.levelname == 'ERROR': + color = colorama.Fore.LIGHTRED_EX + else: + color = colorama.Fore.RED try: + print(color) super().handle(record) finally: record.exc_info = info record.exc_text = cache + print(colorama.Style.RESET_ALL) if __name__ == '__main__': sys.excepthook = exception_hook diff --git a/setup.py b/setup.py index 5a624db..b4341d3 100644 --- a/setup.py +++ b/setup.py @@ -37,6 +37,7 @@ setup(name='ucloud', 'sshtunnel', 'sphinx', 'pynetbox', + 'colorama', 'sphinx-rtd-theme', 'etcd3 @ https://github.com/kragniz/python-etcd3/tarball/master#egg=etcd3', ], From 972bb5a92099ce82c9b777a5040f094b13208241 Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 23 Dec 2019 12:58:04 +0500 Subject: [PATCH 082/284] - Better error reporting. - Flask now uses application's logger instead of its own. - ucloud file scanner refactored. --- scripts/ucloud | 29 ++------ setup.py | 1 + ucloud/api/helper.py | 3 +- ucloud/api/main.py | 32 ++++++++- ucloud/common/etcd_wrapper.py | 2 +- ucloud/common/logging.py | 24 +++++++ ucloud/common/storage_handlers.py | 30 ++++++--- ucloud/filescanner/main.py | 107 ++++++++++-------------------- ucloud/host/main.py | 5 +- ucloud/host/virtualmachine.py | 25 +------ ucloud/imagescanner/main.py | 27 ++++---- ucloud/metadata/__init__.py | 3 + ucloud/metadata/main.py | 15 +++++ ucloud/settings/__init__.py | 8 +-- 14 files changed, 157 insertions(+), 154 deletions(-) create mode 100644 ucloud/common/logging.py diff --git a/scripts/ucloud b/scripts/ucloud index 0a6c5ec..5e8bce6 100755 --- a/scripts/ucloud +++ b/scripts/ucloud @@ -6,44 +6,23 @@ import importlib import multiprocessing as mp import sys -import colorama - from logging.handlers import SysLogHandler from ucloud.configure.main import configure_parser +from ucloud.common.logging import NoTracebackStreamHandler def exception_hook(exc_type, exc_value, exc_traceback): + logger = logging.getLogger(__name__) logger.error( 'Uncaught exception', exc_info=(exc_type, exc_value, exc_traceback) ) - # print('Error: ', end='') - # print(exc_type, exc_value, exc_traceback) -class NoTracebackStreamHandler(logging.StreamHandler): - def handle(self, record): - info, cache = record.exc_info, record.exc_text - record.exc_info, record.exc_text = None, None - - if record.levelname == 'WARNING': - color = colorama.Fore.YELLOW - elif record.levelname == 'ERROR': - color = colorama.Fore.LIGHTRED_EX - else: - color = colorama.Fore.RED - try: - print(color) - super().handle(record) - finally: - record.exc_info = info - record.exc_text = cache - print(colorama.Style.RESET_ALL) +sys.excepthook = exception_hook if __name__ == '__main__': - sys.excepthook = exception_hook - arg_parser = argparse.ArgumentParser() subparsers = arg_parser.add_subparsers(dest="command") @@ -68,7 +47,7 @@ if __name__ == '__main__': arg_parser.print_help() else: # Setting up root logger - logger = logging.getLogger('ucloud') + logger = logging.getLogger(__name__) syslog_handler = SysLogHandler(address='/dev/log') syslog_handler.setLevel(logging.DEBUG) diff --git a/setup.py b/setup.py index b4341d3..2c1c2cb 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,7 @@ setup(name='ucloud', 'colorama', 'sphinx-rtd-theme', 'etcd3 @ https://github.com/kragniz/python-etcd3/tarball/master#egg=etcd3', + 'werkzeug' ], scripts=['scripts/ucloud'], data_files=[(os.path.expanduser('~/ucloud/'), ['conf/ucloud.conf'])], diff --git a/ucloud/api/helper.py b/ucloud/api/helper.py index 9cda36e..e275e46 100755 --- a/ucloud/api/helper.py +++ b/ucloud/api/helper.py @@ -10,7 +10,8 @@ from pyotp import TOTP from ucloud.shared import shared from ucloud.settings import settings -logger = logging.getLogger("ucloud.api.helper") +logger = logging.getLogger(__name__) + def check_otp(name, realm, token): try: diff --git a/ucloud/api/main.py b/ucloud/api/main.py index 05972ff..92c73f5 100644 --- a/ucloud/api/main.py +++ b/ucloud/api/main.py @@ -1,11 +1,14 @@ import json import pynetbox +import logging +import urllib3 from uuid import uuid4 from os.path import join as join_path from flask import Flask, request from flask_restful import Resource, Api +from werkzeug.exceptions import HTTPException from ucloud.common import counters from ucloud.common.vm import VMStatus @@ -15,10 +18,33 @@ from ucloud.shared import shared from . import schemas from .helper import generate_mac, mac2ipv6 -from . import logger + + +def get_parent(obj, attr): + parent = getattr(obj, attr) + child = parent + while parent is not None: + child = parent + parent = getattr(parent, attr) + return child + + +logger = logging.getLogger(__name__) app = Flask(__name__) api = Api(app) +app.logger.handlers.clear() + + +@app.errorhandler(Exception) +def handle_exception(e): + app.logger.error(e) + # pass through HTTP errors + if isinstance(e, HTTPException): + return e + + # now you're handling non-HTTP exceptions only + return {'message': 'Server Error'}, 500 class CreateVM(Resource): @@ -438,8 +464,8 @@ class CreateNetwork(Resource): "is_pool": True, } ) - except Exception: - logger.exception("Exception occur while contacting netbox") + except Exception as err: + app.logger.error(err) return {"message": "Error occured while creating network."} else: network_entry["ipv6"] = prefix["prefix"] diff --git a/ucloud/common/etcd_wrapper.py b/ucloud/common/etcd_wrapper.py index 91149b8..eecf4c7 100644 --- a/ucloud/common/etcd_wrapper.py +++ b/ucloud/common/etcd_wrapper.py @@ -27,7 +27,7 @@ def readable_errors(func): @wraps(func) def wrapper(*args, **kwargs): try: - func(*args, **kwargs) + return func(*args, **kwargs) except etcd3.exceptions.ConnectionFailedError as err: raise etcd3.exceptions.ConnectionFailedError('etcd connection failed') from err except etcd3.exceptions.ConnectionTimeoutError as err: diff --git a/ucloud/common/logging.py b/ucloud/common/logging.py new file mode 100644 index 0000000..945f473 --- /dev/null +++ b/ucloud/common/logging.py @@ -0,0 +1,24 @@ +import logging +import colorama + + +class NoTracebackStreamHandler(logging.StreamHandler): + def handle(self, record): + info, cache = record.exc_info, record.exc_text + record.exc_info, record.exc_text = None, None + + if record.levelname == 'WARNING': + color = colorama.Fore.YELLOW + elif record.levelname in ['ERROR', 'EXCEPTION']: + color = colorama.Fore.LIGHTRED_EX + elif record.levelname == 'INFO': + color = colorama.Fore.LIGHTBLUE_EX + else: + color = colorama.Fore.WHITE + try: + print(color, end='', flush=True) + super().handle(record) + finally: + record.exc_info = info + record.exc_text = cache + print(colorama.Style.RESET_ALL, end='', flush=True) diff --git a/ucloud/common/storage_handlers.py b/ucloud/common/storage_handlers.py index eaad1a5..4b7928e 100644 --- a/ucloud/common/storage_handlers.py +++ b/ucloud/common/storage_handlers.py @@ -11,6 +11,8 @@ from ucloud.settings import settings as config class ImageStorageHandler(ABC): + handler_name = 'base' + def __init__(self, image_base, vm_base): self.image_base = image_base self.vm_base = vm_base @@ -45,13 +47,17 @@ class ImageStorageHandler(ABC): def delete_vm_image(self, path): raise NotImplementedError() - def execute_command(self, command, report=True): + def execute_command(self, command, report=True, error_origin=None): + if not error_origin: + error_origin = self.handler_name + command = list(map(str, command)) try: - output = sp.check_output(command, stderr=sp.PIPE) - except Exception as e: + sp.check_output(command, stderr=sp.PIPE) + except sp.CalledProcessError as e: + _stderr = e.stderr.decode('utf-8').strip() if report: - logger.exception(e) + logger.exception('%s:- %s', error_origin, _stderr) return False return True @@ -66,6 +72,8 @@ class ImageStorageHandler(ABC): class FileSystemBasedImageStorageHandler(ImageStorageHandler): + handler_name = 'Filesystem' + def import_image(self, src, dest, protect=False): dest = join_path(self.image_base, dest) try: @@ -118,17 +126,23 @@ class FileSystemBasedImageStorageHandler(ImageStorageHandler): class CEPHBasedImageStorageHandler(ImageStorageHandler): + handler_name = 'Ceph' + def import_image(self, src, dest, protect=False): dest = join_path(self.image_base, dest) - command = ["rbd", "import", src, dest] + import_command = ["rbd", "import", src, dest] + commands = [import_command] if protect: snap_create_command = ["rbd", "snap", "create", "{}@protected".format(dest)] snap_protect_command = ["rbd", "snap", "protect", "{}@protected".format(dest)] + commands.append(snap_create_command) + commands.append(snap_protect_command) - return self.execute_command(command) and self.execute_command(snap_create_command) and\ - self.execute_command(snap_protect_command) + result = True + for command in commands: + result = result and self.execute_command(command) - return self.execute_command(command) + return result def make_vm_image(self, src, dest): src = join_path(self.image_base, src) diff --git a/ucloud/filescanner/main.py b/ucloud/filescanner/main.py index ff38748..778e942 100755 --- a/ucloud/filescanner/main.py +++ b/ucloud/filescanner/main.py @@ -3,7 +3,6 @@ import os import pathlib import subprocess as sp import time -import sys from uuid import uuid4 @@ -11,30 +10,6 @@ from . import logger from ucloud.settings import settings from ucloud.shared import shared -def getxattr(file, attr): - """Get specified user extended attribute (arg:attr) of a file (arg:file)""" - try: - attr = "user." + attr - value = sp.check_output(['getfattr', file, - '--name', attr, - '--only-values', - '--absolute-names'], stderr=sp.DEVNULL) - value = value.decode("utf-8") - except sp.CalledProcessError as e: - value = None - - return value - - -def setxattr(file, attr, value): - """Set specified user extended attribute (arg:attr) equal to (arg:value) - of a file (arg:file)""" - - attr = "user." + attr - sp.check_output(['setfattr', file, - '--name', attr, - '--value', str(value)]) - def sha512sum(file: str): """Use sha512sum utility to compute sha512 sum of arg:file @@ -60,12 +35,33 @@ def sha512sum(file: str): return None -try: - sp.check_output(['which', 'getfattr']) - sp.check_output(['which', 'setfattr']) -except Exception as e: - logger.error("You don't seems to have both getfattr and setfattr") - sys.exit(1) +def track_file(file, base_dir): + file_id = uuid4() + + # Get Username + owner = pathlib.Path(file).parts[len(pathlib.Path(base_dir).parts)] + + # Get Creation Date of File + # Here, we are assuming that ctime is creation time + # which is mostly not true. + creation_date = time.ctime(os.stat(file).st_ctime) + + file_path = pathlib.Path(file).parts[-1] + + # Create Entry + entry_key = os.path.join(settings['etcd']['file_prefix'], str(file_id)) + entry_value = { + "filename": file_path, + "owner": owner, + "sha512sum": sha512sum(file), + "creation_date": creation_date, + "size": os.path.getsize(file) + } + + logger.info("Tracking %s", file) + + shared.etcd_client.put(entry_key, entry_value, value_in_json=True) + os.setxattr(file, 'user.utracked', b'True') def main(): @@ -75,48 +71,15 @@ def main(): files = glob.glob("{}/**".format(base_dir), recursive=True) # Retain only Files - files = list(filter(os.path.isfile, files)) + files = [file for file in files if os.path.isfile(file)] - untracked_files = list( - filter(lambda f: not bool(getxattr(f, "utracked")), files) - ) - - tracked_files = list( - filter(lambda f: f not in untracked_files, files) - ) - for file in untracked_files: - file_id = uuid4() - - # Get Username - owner = pathlib.Path(file).parts[len(pathlib.Path(base_dir).parts)] - - # Get Creation Date of File - # Here, we are assuming that ctime is creation time - # which is mostly not true. - creation_date = time.ctime(os.stat(file).st_ctime) - - # Get File Size - size = os.path.getsize(file) - - # Compute sha512 sum - sha_sum = sha512sum(file) - - file_path = pathlib.Path(file).parts[-1] - - # Create Entry - entry_key = os.path.join(settings['etcd']['file_prefix'], str(file_id)) - entry_value = { - "filename": file_path, - "owner": owner, - "sha512sum": sha_sum, - "creation_date": creation_date, - "size": size - } - - logger.info("Tracking %s", file) - - shared.etcd_client.put(entry_key, entry_value, value_in_json=True) - setxattr(file, "utracked", True) + untracked_files = [] + for file in files: + try: + os.getxattr(file, 'user.utracked') + except OSError: + track_file(file, base_dir) + untracked_files.append(file) if __name__ == "__main__": diff --git a/ucloud/host/main.py b/ucloud/host/main.py index f78f629..be4f501 100755 --- a/ucloud/host/main.py +++ b/ucloud/host/main.py @@ -16,8 +16,7 @@ vmm = virtualmachine.VMM() def update_heartbeat(hostname): """Update Last HeartBeat Time for :param hostname: in etcd""" - client = shared.etcd_client - host_pool = HostPool(client) + host_pool = shared.host_pool this_host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) while True: @@ -27,7 +26,7 @@ def update_heartbeat(hostname): def main(hostname): - host_pool = HostPool(shared.etcd_client) + host_pool = shared.host_pool host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) assert host is not None, "No such host with name = {}".format(hostname) diff --git a/ucloud/host/virtualmachine.py b/ucloud/host/virtualmachine.py index cc06ce3..b3a1d2a 100755 --- a/ucloud/host/virtualmachine.py +++ b/ucloud/host/virtualmachine.py @@ -44,29 +44,7 @@ def capture_all_exception(func): try: func(*args, **kwargs) except Exception: - logger.info("Exception absorbed by captual_all_exception()") - logger.exception(func.__name__) - - return wrapper - - -def need_running_vm(func): - @wraps(func) - def wrapper(self, e): - vm = self.get_vm(self.running_vms, e.key) - if vm: - try: - status = vm.handle.command("query-status") - logger.debug("VM Status Check - %s", status) - except Exception as exception: - logger.info("%s failed - VM %s %s", func.__name__, e, exception) - else: - return func(e) - - return None - else: - logger.info("%s failed because VM %s is not running", func.__name__, e.key) - return None + logger.exception('Unhandled exception occur in %s. For more details see Syslog.', __name__) return wrapper @@ -168,7 +146,6 @@ class VMM: self.create(vm_entry) self.launch_vm(vm_entry) - @need_running_vm @capture_all_exception def stop(self, vm_entry): vm = self.get_vm(self.running_vms, vm_entry.key) diff --git a/ucloud/imagescanner/main.py b/ucloud/imagescanner/main.py index d164ea3..0d2fbf2 100755 --- a/ucloud/imagescanner/main.py +++ b/ucloud/imagescanner/main.py @@ -1,6 +1,6 @@ import json import os -import subprocess +import subprocess as sp import sys from os.path import isdir @@ -13,7 +13,7 @@ from ucloud.imagescanner import logger def qemu_img_type(path): qemu_img_info_command = ["qemu-img", "info", "--output", "json", path] try: - qemu_img_info = subprocess.check_output(qemu_img_info_command) + qemu_img_info = sp.check_output(qemu_img_info_command) except Exception as e: logger.exception(e) return None @@ -29,7 +29,7 @@ def check(): ) try: - subprocess.check_output(['which', 'qemu-img']) + sp.check_output(['which', 'qemu-img']) except Exception: print("qemu-img missing") sys.exit(1) @@ -67,29 +67,30 @@ def main(): if qemu_img_type(image_full_path) == "qcow2": try: # Convert .qcow2 to .raw - subprocess.check_output(qemu_img_convert_command) - except Exception as e: - logger.exception(e) + sp.check_output(qemu_img_convert_command,) + + except sp.CalledProcessError: + logger.exception('Image convertion from .qcow2 to .raw failed.') else: # Import and Protect r_status = shared.storage_handler.import_image(src="image.raw", - dest=image_uuid, - protect=True) + dest=image_uuid, + protect=True) if r_status: # Everything is successfully done image.value["status"] = "CREATED" shared.etcd_client.put(image.key, json.dumps(image.value)) + finally: + try: + os.remove("image.raw") + except Exception: + pass else: # The user provided image is either not found or of invalid format image.value["status"] = "INVALID_IMAGE" shared.etcd_client.put(image.key, json.dumps(image.value)) - try: - os.remove("image.raw") - except Exception: - pass - if __name__ == "__main__": main() diff --git a/ucloud/metadata/__init__.py b/ucloud/metadata/__init__.py index e69de29..eea436a 100644 --- a/ucloud/metadata/__init__.py +++ b/ucloud/metadata/__init__.py @@ -0,0 +1,3 @@ +import logging + +logger = logging.getLogger(__name__) diff --git a/ucloud/metadata/main.py b/ucloud/metadata/main.py index 5526084..adec9e7 100644 --- a/ucloud/metadata/main.py +++ b/ucloud/metadata/main.py @@ -2,12 +2,27 @@ import os from flask import Flask, request from flask_restful import Resource, Api +from werkzeug.exceptions import HTTPException + from ucloud.settings import settings from ucloud.shared import shared app = Flask(__name__) api = Api(app) +app.logger.handlers.clear() + + +@app.errorhandler(Exception) +def handle_exception(e): + app.logger.error(e) + # pass through HTTP errors + if isinstance(e, HTTPException): + return e + + # now you're handling non-HTTP exceptions only + return {'message': 'Server Error'}, 500 + def get_vm_entry(mac_addr): return next(filter(lambda vm: mac_addr in list(zip(*vm.network))[1], shared.vm_pool.vms), None) diff --git a/ucloud/settings/__init__.py b/ucloud/settings/__init__.py index 2c77300..b651aa2 100644 --- a/ucloud/settings/__init__.py +++ b/ucloud/settings/__init__.py @@ -5,7 +5,6 @@ import os from ucloud.common.etcd_wrapper import Etcd3Wrapper - logger = logging.getLogger(__name__) @@ -14,8 +13,9 @@ class CustomConfigParser(configparser.RawConfigParser): try: result = super().__getitem__(key) except KeyError as err: - raise KeyError("Key '{}' not found in config file"\ - .format(key)) from err + raise KeyError( + 'Key \'{}\' not found in configuration. Make sure you configure ucloud.'.format(key) + ) from err else: return result @@ -78,7 +78,7 @@ class Settings(object): if config_from_etcd: self.config_parser.read_dict(config_from_etcd.value) else: - raise KeyError("Key '{}' not found in etcd".format(self.config_key)) + raise KeyError("Key '{}' not found in etcd. Please configure ucloud.".format(self.config_key)) def __getitem__(self, key): self.read_values_from_etcd() From f79097cae9fbe7894c31a6c0237619c51728725a Mon Sep 17 00:00:00 2001 From: meow Date: Tue, 24 Dec 2019 15:27:21 +0500 Subject: [PATCH 083/284] Fix logging --- scripts/ucloud | 2 +- ucloud/imagescanner/main.py | 15 --------------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/scripts/ucloud b/scripts/ucloud index 5e8bce6..3ddbb5a 100755 --- a/scripts/ucloud +++ b/scripts/ucloud @@ -47,7 +47,7 @@ if __name__ == '__main__': arg_parser.print_help() else: # Setting up root logger - logger = logging.getLogger(__name__) + logger = logging.getLogger('ucloud') syslog_handler = SysLogHandler(address='/dev/log') syslog_handler.setLevel(logging.DEBUG) diff --git a/ucloud/imagescanner/main.py b/ucloud/imagescanner/main.py index 0d2fbf2..e215c88 100755 --- a/ucloud/imagescanner/main.py +++ b/ucloud/imagescanner/main.py @@ -1,9 +1,7 @@ import json import os import subprocess as sp -import sys -from os.path import isdir from os.path import join as join_path from ucloud.settings import settings from ucloud.shared import shared @@ -21,19 +19,6 @@ def qemu_img_type(path): qemu_img_info = json.loads(qemu_img_info.decode("utf-8")) return qemu_img_info["format"] -def check(): - """ check whether settings are sane, refuse to start if they aren't """ - if settings['storage']['storage_backend'] == 'filesystem' and not isdir(settings['storage']['image_dir']): - sys.exit("You have set STORAGE_BACKEND to filesystem, but " - "{} does not exist. Refusing to start".format(settings['storage']['image_dir']) - ) - - try: - sp.check_output(['which', 'qemu-img']) - except Exception: - print("qemu-img missing") - sys.exit(1) - def main(): # We want to get images entries that requests images to be created From ec3cf49799e9cf17dcd23fa3d09209597d1d85ea Mon Sep 17 00:00:00 2001 From: meow Date: Thu, 26 Dec 2019 12:24:19 +0500 Subject: [PATCH 084/284] Create radvd configuration and start it <--> VM's which is being started has IPv6 network which is global --- ucloud/host/virtualmachine.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ucloud/host/virtualmachine.py b/ucloud/host/virtualmachine.py index b3a1d2a..dbd13de 100755 --- a/ucloud/host/virtualmachine.py +++ b/ucloud/host/virtualmachine.py @@ -8,6 +8,7 @@ import os import subprocess as sp import tempfile import time +import ipaddress from functools import wraps from string import Template @@ -90,7 +91,9 @@ class VMM: tap_id=tap, ip=network_ipv6) all_networks = self.etcd_client.get_prefix('/v1/network/', value_in_json=True) - update_radvd_conf(all_networks) + + if ipaddress.ip_network(network_ipv6).is_global: + update_radvd_conf(all_networks) command += " -netdev tap,id=vmnet{net_id},ifname={tap},script=no,downscript=no" \ " -device virtio-net-pci,netdev=vmnet{net_id},mac={mac}" \ @@ -361,7 +364,7 @@ def update_radvd_conf(all_networks): networks = { net.value['ipv6']: net.value['id'] for net in all_networks - if net.value.get('ipv6') + if net.value.get('ipv6') and ipaddress.ip_network(net.value.get('ipv6')).is_global } radvd_template = open(os.path.join(network_script_base, 'radvd-template.conf'), 'r').read() From cd9d4cb78c9069e166bb618c78cd302514dd9c70 Mon Sep 17 00:00:00 2001 From: meow Date: Thu, 26 Dec 2019 14:30:15 +0500 Subject: [PATCH 085/284] Fix bug that cause failure of image resizing when creating vm --- ucloud/common/storage_handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ucloud/common/storage_handlers.py b/ucloud/common/storage_handlers.py index 4b7928e..4a17ec7 100644 --- a/ucloud/common/storage_handlers.py +++ b/ucloud/common/storage_handlers.py @@ -89,7 +89,7 @@ class FileSystemBasedImageStorageHandler(ImageStorageHandler): src = join_path(self.image_base, src) dest = join_path(self.vm_base, dest) try: - shutil.copy(src, dest) + shutil.copyfile(src, dest) except Exception as e: logger.exception(e) return False From ba515f0b48a079b9b6bd7e74cbdd842c5696ca65 Mon Sep 17 00:00:00 2001 From: meow Date: Sat, 28 Dec 2019 15:39:11 +0500 Subject: [PATCH 086/284] Refactoring, VMM added, uncloud-host mostly new, migration is better now --- setup.py | 2 +- ucloud/api/main.py | 17 +- ucloud/common/network.py | 2 +- ucloud/common/request.py | 1 + ucloud/common/schemas.py | 39 +++ ucloud/common/storage_handlers.py | 8 +- ucloud/common/vm.py | 6 + ucloud/host/main.py | 45 +-- ucloud/host/virtualmachine.py | 449 ++++++++++-------------------- ucloud/scheduler/helper.py | 2 +- ucloud/scheduler/main.py | 35 +-- ucloud/vmm/__init__.py | 181 ++++++++++++ 12 files changed, 423 insertions(+), 364 deletions(-) create mode 100644 ucloud/common/schemas.py create mode 100644 ucloud/vmm/__init__.py diff --git a/setup.py b/setup.py index 2c1c2cb..51d21b8 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ setup(name='ucloud', 'colorama', 'sphinx-rtd-theme', 'etcd3 @ https://github.com/kragniz/python-etcd3/tarball/master#egg=etcd3', - 'werkzeug' + 'werkzeug', 'marshmallow' ], scripts=['scripts/ucloud'], data_files=[(os.path.expanduser('~/ucloud/'), ['conf/ucloud.conf'])], diff --git a/ucloud/api/main.py b/ucloud/api/main.py index 92c73f5..91cbead 100644 --- a/ucloud/api/main.py +++ b/ucloud/api/main.py @@ -1,7 +1,6 @@ import json import pynetbox import logging -import urllib3 from uuid import uuid4 from os.path import join as join_path @@ -78,6 +77,7 @@ class CreateVM(Resource): "vnc_socket": "", "network": list(zip(data["network"], macs, tap_ids)), "metadata": {"ssh-keys": []}, + "in_migration": False } shared.etcd_client.put(vm_key, vm_entry, value_in_json=True) @@ -216,16 +216,13 @@ class VMMigration(Resource): if validator.is_valid(): vm = shared.vm_pool.get(data["uuid"]) + r = RequestEntry.from_scratch(type=RequestType.InitVMMigration, + uuid=vm.uuid, + hostname=join_path( + settings['etcd']['host_prefix'], validator.destination.value + ), + request_prefix=settings['etcd']['request_prefix']) - r = RequestEntry.from_scratch( - type=RequestType.ScheduleVM, - uuid=vm.uuid, - destination=join_path( - settings['etcd']['host_prefix'], validator.destination.value - ), - migration=True, - request_prefix=settings['etcd']['request_prefix'] - ) shared.request_pool.put(r) return {"message": "VM Migration Initialization Queued"}, 200 else: diff --git a/ucloud/common/network.py b/ucloud/common/network.py index df7151b..629e92a 100644 --- a/ucloud/common/network.py +++ b/ucloud/common/network.py @@ -30,7 +30,7 @@ def generate_mac(uaa=False, multicast=False, oui=None, separator=':', byte_fmt=' def create_dev(script, _id, dev, ip=None): - command = [script, _id, dev] + command = [script, str(_id), dev] if ip: command.append(ip) try: diff --git a/ucloud/common/request.py b/ucloud/common/request.py index 1e4594d..2d9be44 100644 --- a/ucloud/common/request.py +++ b/ucloud/common/request.py @@ -19,6 +19,7 @@ class RequestType: class RequestEntry(SpecificEtcdEntryBase): def __init__(self, e): + self.destination_host_key = None self.type = None # type: str self.migration = None # type: bool self.destination = None # type: str diff --git a/ucloud/common/schemas.py b/ucloud/common/schemas.py new file mode 100644 index 0000000..a592ec2 --- /dev/null +++ b/ucloud/common/schemas.py @@ -0,0 +1,39 @@ +import bitmath + +from marshmallow import fields, Schema + + +class StorageUnit(fields.Field): + def _serialize(self, value, attr, obj, **kwargs): + return str(value) + + def _deserialize(self, value, attr, data, **kwargs): + return bitmath.parse_string_unsafe(value) + + +class SpecsSchema(Schema): + cpu = fields.Int() + ram = StorageUnit() + os_ssd = StorageUnit(data_key='os-ssd', attribute='os-ssd') + hdd = fields.List(StorageUnit()) + + +class VMSchema(Schema): + name = fields.Str() + owner = fields.Str() + owner_realm = fields.Str() + specs = fields.Nested(SpecsSchema) + status = fields.Str() + log = fields.List(fields.Str()) + vnc_socket = fields.Str() + image_uuid = fields.Str() + hostname = fields.Str() + metadata = fields.Dict() + network = fields.List(fields.Tuple((fields.Str(), fields.Str(), fields.Int()))) + in_migration = fields.Bool() + + +class NetworkSchema(Schema): + _id = fields.Int(data_key='id', attribute='id') + _type = fields.Str(data_key='type', attribute='type') + ipv6 = fields.Str() diff --git a/ucloud/common/storage_handlers.py b/ucloud/common/storage_handlers.py index 4a17ec7..d2190ba 100644 --- a/ucloud/common/storage_handlers.py +++ b/ucloud/common/storage_handlers.py @@ -19,8 +19,8 @@ class ImageStorageHandler(ABC): def import_image(self, image_src, image_dest, protect=False): """Put an image at the destination - :param src: An Image file - :param dest: A path where :param src: is to be put. + :param image_src: An Image file + :param image_dest: A path where :param src: is to be put. :param protect: If protect is true then the dest is protect (readonly etc) The obj must exist on filesystem. """ @@ -30,8 +30,8 @@ class ImageStorageHandler(ABC): def make_vm_image(self, image_path, path): """Copy image from src to dest - :param src: A path - :param dest: A path + :param image_path: A path + :param path: A path src and destination must be on same storage system i.e both on file system or both on CEPH etc. """ diff --git a/ucloud/common/vm.py b/ucloud/common/vm.py index 0fb5cea..238f19d 100644 --- a/ucloud/common/vm.py +++ b/ucloud/common/vm.py @@ -12,6 +12,12 @@ class VMStatus: error = "ERROR" # An error occurred that cannot be resolved automatically +def declare_stopped(vm): + vm['hostname'] = '' + vm['in_migration'] = False + vm['status'] = VMStatus.stopped + + class VMEntry(SpecificEtcdEntryBase): def __init__(self, e): diff --git a/ucloud/host/main.py b/ucloud/host/main.py index be4f501..8a7dbe7 100755 --- a/ucloud/host/main.py +++ b/ucloud/host/main.py @@ -1,17 +1,16 @@ import argparse import multiprocessing as mp import time -import sys from ucloud.common.request import RequestEntry, RequestType -from ucloud.common.host import HostPool from ucloud.shared import shared from ucloud.settings import settings +from ucloud.common.vm import VMStatus +from ucloud.vmm import VMM +from os.path import join as join_path from . import virtualmachine, logger -vmm = virtualmachine.VMM() - def update_heartbeat(hostname): """Update Last HeartBeat Time for :param hostname: in etcd""" @@ -25,6 +24,16 @@ def update_heartbeat(hostname): time.sleep(10) +def maintenance(): + vmm = VMM() + running_vms = vmm.discover() + for vm_uuid in running_vms: + if vmm.is_running(vm_uuid) and vmm.get_status(vm_uuid) == 'running': + vm = shared.vm_pool.get(join_path(settings['etcd']['vm_prefix'], vm_uuid)) + vm.status = VMStatus.running + shared.vm_pool.put(vm) + + def main(hostname): host_pool = shared.host_pool host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) @@ -34,8 +43,7 @@ def main(hostname): heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(hostname,)) heartbeat_updating_process.start() except Exception as e: - logger.exception(e) - sys.exit("No Need To Go Further. ucloud-host heartbeat updating mechanism is not working") + raise e.__class__('ucloud-host heartbeat updating mechanism is not working') from e for events_iterator in [ shared.etcd_client.get_prefix(settings['etcd']['request_prefix'], value_in_json=True), @@ -45,36 +53,37 @@ def main(hostname): request_event = RequestEntry(request_event) if request_event.type == "TIMEOUT": - vmm.maintenance(host) - continue + maintenance() - # If the event is directed toward me OR I am destination of a InitVMMigration - if request_event.hostname == host.key or request_event.destination == host.key: + if request_event.hostname == host.key: logger.debug("VM Request: %s", request_event) shared.request_pool.client.client.delete(request_event.key) - vm_entry = shared.vm_pool.get(request_event.uuid) + vm_entry = shared.etcd_client.get(join_path(settings['etcd']['vm_prefix'], request_event.uuid)) if vm_entry: + vm = virtualmachine.VM(vm_entry) if request_event.type == RequestType.StartVM: - vmm.start(vm_entry) + vm.start() elif request_event.type == RequestType.StopVM: - vmm.stop(vm_entry) + vm.stop() elif request_event.type == RequestType.DeleteVM: - vmm.delete(vm_entry) + vm.delete() elif request_event.type == RequestType.InitVMMigration: - vmm.start(vm_entry, host.key) + vm.start(destination_host_key=host.key) elif request_event.type == RequestType.TransferVM: - vmm.transfer(request_event) + host = host_pool.get(request_event.destination_host_key) + if host: + vm.migrate(destination=host.hostname) + else: + logger.error('Host %s not found!', request_event.destination_host_key) else: logger.info("VM Entry missing") - logger.info("Running VMs %s", vmm.running_vms) - if __name__ == "__main__": argparser = argparse.ArgumentParser() diff --git a/ucloud/host/virtualmachine.py b/ucloud/host/virtualmachine.py index dbd13de..6d25205 100755 --- a/ucloud/host/virtualmachine.py +++ b/ucloud/host/virtualmachine.py @@ -6,344 +6,189 @@ import os import subprocess as sp -import tempfile -import time import ipaddress -from functools import wraps from string import Template -from typing import Union from os.path import join as join_path -import bitmath -import sshtunnel - -from ucloud.common.helpers import get_ipv6_address from ucloud.common.request import RequestEntry, RequestType -from ucloud.common.vm import VMEntry, VMStatus -from ucloud.common.network import create_dev, delete_network_interface, find_free_port +from ucloud.common.vm import VMStatus, declare_stopped +from ucloud.common.network import create_dev, delete_network_interface +from ucloud.common.schemas import VMSchema, NetworkSchema from ucloud.host import logger from ucloud.shared import shared from ucloud.settings import settings +from ucloud.vmm import VMM -from . import qmp +from marshmallow import ValidationError + + +def maintenance(): + pass class VM: - def __init__(self, key, handle, vnc_socket_file): - self.key = key # type: str - self.handle = handle # type: qmp.QEMUMachine - self.vnc_socket_file = vnc_socket_file # type: tempfile.NamedTemporaryFile - - def __repr__(self): - return "VM({})".format(self.key) - - -def capture_all_exception(func): - @wraps(func) - def wrapper(*args, **kwargs): + def __init__(self, vm_entry): + self.schema = VMSchema() + self.vmm = VMM() + self.key = vm_entry.key try: - func(*args, **kwargs) - except Exception: - logger.exception('Unhandled exception occur in %s. For more details see Syslog.', __name__) - - return wrapper - - -class VMM: - def __init__(self): - self.etcd_client = shared.etcd_client - self.storage_handler = shared.storage_handler - self.running_vms = [] - - def get_start_command_args(self, vm_entry, vnc_sock_filename: str, migration=False, migration_port=None): - threads_per_core = 1 - vm_memory = int(bitmath.parse_string_unsafe(vm_entry.specs['ram']).to_MB()) - vm_cpus = int(vm_entry.specs['cpu']) - vm_uuid = vm_entry.uuid - vm_networks = vm_entry.network - - command = '-name {}_{}'.format(vm_entry.owner, vm_entry.name) - - command += ' -drive file={},format=raw,if=virtio,cache=none'.format( - self.storage_handler.qemu_path_string(vm_uuid) - ) - command += ' -device virtio-rng-pci -vnc unix:{}'.format(vnc_sock_filename) - command += ' -m {} -smp cores={},threads={}'.format( - vm_memory, vm_cpus, threads_per_core - ) - - if migration: - command += ' -incoming tcp:[::]:{}'.format(migration_port) - - for network_mac_and_tap in vm_networks: - network_name, mac, tap = network_mac_and_tap - - _key = os.path.join(settings['etcd']['network_prefix'], vm_entry.owner, network_name) - network = self.etcd_client.get(_key, value_in_json=True) - network_type = network.value["type"] - network_id = str(network.value["id"]) - network_ipv6 = network.value["ipv6"] - - if network_type == "vxlan": - tap = create_vxlan_br_tap(_id=network_id, - _dev=settings['network']['vxlan_phy_dev'], - tap_id=tap, - ip=network_ipv6) - all_networks = self.etcd_client.get_prefix('/v1/network/', value_in_json=True) - - if ipaddress.ip_network(network_ipv6).is_global: - update_radvd_conf(all_networks) - - command += " -netdev tap,id=vmnet{net_id},ifname={tap},script=no,downscript=no" \ - " -device virtio-net-pci,netdev=vmnet{net_id},mac={mac}" \ - .format(tap=tap, net_id=network_id, mac=mac) - - return command.split(" ") - - def create_vm_object(self, vm_entry, migration=False, migration_port=None): - vnc_sock_file = tempfile.NamedTemporaryFile() - - qemu_args = self.get_start_command_args( - vm_entry=vm_entry, - vnc_sock_filename=vnc_sock_file.name, - migration=migration, - migration_port=migration_port, - ) - qemu_machine = qmp.QEMUMachine("/usr/bin/qemu-system-x86_64", args=qemu_args) - return VM(vm_entry.key, qemu_machine, vnc_sock_file) - - @staticmethod - def get_vm(vm_list: list, vm_key) -> Union[VM, None]: - return next((vm for vm in vm_list if vm.key == vm_key), None) - - @capture_all_exception - def create(self, vm_entry: VMEntry): - if self.storage_handler.is_vm_image_exists(vm_entry.uuid): - # File Already exists. No Problem Continue - logger.debug("Image for vm %s exists", vm_entry.uuid) - return None + self.vm = self.schema.loads(vm_entry.value) + except ValidationError: + logger.exception('Couldn\'t validate VM Entry', vm_entry.value) + self.vm = None else: - vm_hdd = int(bitmath.parse_string_unsafe(vm_entry.specs["os-ssd"]).to_MB()) - if self.storage_handler.make_vm_image(src=vm_entry.image_uuid, dest=vm_entry.uuid): - if not self.storage_handler.resize_vm_image(path=vm_entry.uuid, size=vm_hdd): - vm_entry.status = VMStatus.error - else: - logger.info("New VM Created") + self.uuid = vm_entry.key.split('/')[-1] + self.host_key = self.vm['hostname'] - @capture_all_exception - def start(self, vm_entry: VMEntry, destination_host_key=None): - _vm = self.get_vm(self.running_vms, vm_entry.key) + def get_qemu_args(self): + command = ( + '-name {owner}_{name}' + ' -drive file={file},format=raw,if=virtio,cache=none' + ' -device virtio-rng-pci' + ' -m {memory} -smp cores={cores},threads={threads}' + ).format(owner=self.vm['owner'], name=self.vm['name'], + memory=int(self.vm['specs']['ram'].to_MB()), cores=self.vm['specs']['cpu'], + threads=1, file=shared.storage_handler.qemu_path_string(self.uuid)) - # VM already running. No need to proceed further. - if _vm: - logger.info("VM %s already running" % vm_entry.uuid) - return - else: - logger.info("Trying to start %s" % vm_entry.uuid) - if destination_host_key: - migration_port = find_free_port() - self.launch_vm(vm_entry, migration=True, migration_port=migration_port, - destination_host_key=destination_host_key) - else: - self.create(vm_entry) - self.launch_vm(vm_entry) + return command.split(' ') - @capture_all_exception - def stop(self, vm_entry): - vm = self.get_vm(self.running_vms, vm_entry.key) - vm.handle.shutdown() - if not vm.handle.is_running(): - vm_entry.add_log("Shutdown successfully") - vm_entry.declare_stopped() - shared.vm_pool.put(vm_entry) - self.running_vms.remove(vm) - delete_vm_network(vm_entry) + def start(self, destination_host_key=None): + migration = False + if destination_host_key: + migration = True - @capture_all_exception - def delete(self, vm_entry): - logger.info("Deleting VM | %s", vm_entry) - self.stop(vm_entry) - - if self.storage_handler.is_vm_image_exists(vm_entry.uuid): - r_status = self.storage_handler.delete_vm_image(vm_entry.uuid) - if r_status: - shared.etcd_client.client.delete(vm_entry.key) - else: - shared.etcd_client.client.delete(vm_entry.key) - - @capture_all_exception - def transfer(self, request_event): - # This function would run on source host i.e host on which the vm - # is running initially. This host would be responsible for transferring - # vm state to destination host. - - _host, _port = request_event.parameters["host"], request_event.parameters["port"] - _uuid = request_event.uuid - _destination = request_event.destination_host_key - vm = self.get_vm(self.running_vms, join_path(settings['etcd']['vm_prefix'], _uuid)) - - if vm: - tunnel = sshtunnel.SSHTunnelForwarder( - _host, - ssh_username=settings['ssh']['username'], - ssh_pkey=settings['ssh']['private_key_path'], - remote_bind_address=("127.0.0.1", _port), - ssh_proxy_enabled=True, - ssh_proxy=(_host, 22) - ) - try: - tunnel.start() - except sshtunnel.BaseSSHTunnelForwarderError: - logger.exception("Couldn't establish connection to (%s, 22)", _host) - else: - vm.handle.command( - "migrate", uri="tcp:0.0.0.0:{}".format(tunnel.local_bind_port) - ) - - status = vm.handle.command("query-migrate")["status"] - while status not in ["failed", "completed"]: - time.sleep(2) - status = vm.handle.command("query-migrate")["status"] - - with shared.vm_pool.get_put(request_event.uuid) as source_vm: - if status == "failed": - source_vm.add_log("Migration Failed") - elif status == "completed": - # If VM is successfully migrated then shutdown the VM - # on this host and update hostname to destination host key - source_vm.add_log("Successfully migrated") - source_vm.hostname = _destination - self.running_vms.remove(vm) - vm.handle.shutdown() - source_vm.in_migration = False # VM transfer finished - finally: - tunnel.close() - - @capture_all_exception - def launch_vm(self, vm_entry, migration=False, migration_port=None, destination_host_key=None): - logger.info("Starting %s" % vm_entry.key) - - vm = self.create_vm_object(vm_entry, migration=migration, migration_port=migration_port) + self.create() try: - vm.handle.launch() - except Exception: - logger.exception("Error Occured while starting VM") - vm.handle.shutdown() - - if migration: - # We don't care whether MachineError or any other error occurred - pass - else: - # Error during typical launch of a vm - vm.handle.shutdown() - vm_entry.declare_killed() - shared.vm_pool.put(vm_entry) + network_args = self.create_network_dev() + except Exception as err: + declare_stopped(self.vm) + self.vm['log'].append('Cannot Setup Network Properly') + logger.error('Cannot Setup Network Properly for vm %s', self.uuid, exc_info=err) else: - vm_entry.vnc_socket = vm.vnc_socket_file.name - self.running_vms.append(vm) + self.vmm.start(uuid=self.uuid, migration=migration, + *self.get_qemu_args(), *network_args) - if migration: - vm_entry.in_migration = True + status = self.vmm.get_status(self.uuid) + if status == 'running': + self.vm['status'] = VMStatus.running + self.vm['vnc_socket'] = self.vmm.get_vnc(self.uuid) + elif status == 'inmigrate': r = RequestEntry.from_scratch( - type=RequestType.TransferVM, - hostname=vm_entry.hostname, - parameters={"host": get_ipv6_address(), "port": migration_port}, - uuid=vm_entry.uuid, - destination_host_key=destination_host_key, + type=RequestType.TransferVM, # Transfer VM + hostname=self.host_key, # Which VM should get this request. It is source host + uuid=self.uuid, # uuid of VM + destination_host_key=destination_host_key, # Where source host transfer VM request_prefix=settings['etcd']['request_prefix'] ) shared.request_pool.put(r) else: - # Typical launching of a vm - vm_entry.status = VMStatus.running - vm_entry.add_log("Started successfully") + self.stop() + declare_stopped(self.vm) - shared.vm_pool.put(vm_entry) + self.sync() - @capture_all_exception - def maintenance(self, host): - # To capture vm running according to running_vms list + def stop(self): + self.vmm.stop(self.uuid) + self.delete_network_dev() + declare_stopped(self.vm) + self.sync() - # This is to capture successful migration of a VM. - # Suppose, this host is running "vm1" and user initiated - # request to migrate this "vm1" to some other host. On, - # successful migration the destination host would set - # the vm hostname to itself. Thus, we are checking - # whether this host vm is successfully migrated. If yes - # then we shutdown "vm1" on this host. - logger.debug("Starting Maintenance!!") - to_be_removed = [] - for running_vm in self.running_vms: - with shared.vm_pool.get_put(running_vm.key) as vm_entry: - if vm_entry.hostname != host.key and not vm_entry.in_migration: - running_vm.handle.shutdown() - logger.info("VM migration not completed successfully.") - to_be_removed.append(running_vm) + def migrate(self, destination): + self.vmm.transfer(src_uuid=self.uuid, dest_uuid=self.uuid, host=destination) - for r in to_be_removed: - self.running_vms.remove(r) + def create_network_dev(self): + command = '' + for network_mac_and_tap in self.vm['network']: + network_name, mac, tap = network_mac_and_tap - # To check vm running according to etcd entries - alleged_running_vms = shared.vm_pool.by_status("RUNNING", shared.vm_pool.by_host(host.key)) + _key = os.path.join(settings['etcd']['network_prefix'], self.vm['owner'], network_name) + network = shared.etcd_client.get(_key, value_in_json=True) + network_schema = NetworkSchema() + try: + network = network_schema.load(network.value) + except ValidationError: + continue - for vm_entry in alleged_running_vms: - _vm = self.get_vm(self.running_vms, vm_entry.key) - # Whether, the allegedly running vm is in our - # running_vms list or not if it is said to be - # running on this host but it is not then we - # need to shut it down + if network['type'] == "vxlan": + tap = create_vxlan_br_tap(_id=network['id'], + _dev=settings['network']['vxlan_phy_dev'], + tap_id=tap, + ip=network['ipv6']) - # This is to capture poweroff/shutdown of a VM - # initiated by user inside VM. OR crash of VM by some - # user running process - if (_vm and not _vm.handle.is_running()) or not _vm: - logger.debug("_vm = %s, is_running() = %s" % (_vm, _vm.handle.is_running())) - vm_entry.add_log("""{} is not running but is said to be running. - So, shutting it down and declare it killed""".format(vm_entry.key)) - vm_entry.declare_killed() - shared.vm_pool.put(vm_entry) - if _vm: - self.running_vms.remove(_vm) + all_networks = shared.etcd_client.get_prefix(settings['etcd']['network_prefix'], + value_in_json=True) + + if ipaddress.ip_network(network['ipv6']).is_global: + update_radvd_conf(all_networks) + + command += '-netdev tap,id=vmnet{net_id},ifname={tap},script=no,downscript=no' \ + ' -device virtio-net-pci,netdev=vmnet{net_id},mac={mac}' \ + .format(tap=tap, net_id=network['id'], mac=mac) + + return command.split(' ') + + def delete_network_dev(self): + try: + for network in self.vm['network']: + network_name = network[0] + _ = network[1] # tap_mac + tap_id = network[2] + + delete_network_interface('tap{}'.format(tap_id)) + + owners_vms = shared.vm_pool.by_owner(self.vm['owner']) + owners_running_vms = shared.vm_pool.by_status(VMStatus.running, + _vms=owners_vms) + + networks = map( + lambda n: n[0], map(lambda vm: vm.network, owners_running_vms) + ) + networks_in_use_by_user_vms = [vm[0] for vm in networks] + if network_name not in networks_in_use_by_user_vms: + network_entry = resolve_network(network[0], self.vm['owner']) + if network_entry: + network_type = network_entry.value["type"] + network_id = network_entry.value["id"] + if network_type == "vxlan": + delete_network_interface('br{}'.format(network_id)) + delete_network_interface('vxlan{}'.format(network_id)) + except Exception: + logger.exception("Exception in network interface deletion") + + def create(self): + if shared.storage_handler.is_vm_image_exists(self.uuid): + # File Already exists. No Problem Continue + logger.debug("Image for vm %s exists", self.uuid) + else: + if shared.storage_handler.make_vm_image(src=self.vm['image_uuid'], dest=self.uuid): + if not shared.storage_handler.resize_vm_image(path=self.uuid, + size=int(self.vm['specs']['os-ssd'].to_MB())): + self.vm['status'] = VMStatus.error + else: + logger.info("New VM Created") + + def sync(self): + shared.etcd_client.put(self.key, self.schema.dump(self.vm), value_in_json=True) + + def delete(self): + self.stop() + + if shared.storage_handler.is_vm_image_exists(self.uuid): + r_status = shared.storage_handler.delete_vm_image(self.uuid) + if r_status: + shared.etcd_client.client.delete(self.key) + else: + shared.etcd_client.client.delete(self.key) def resolve_network(network_name, network_owner): - network = shared.etcd_client.get(join_path(settings['etcd']['network_prefix'], - network_owner, - network_name), - value_in_json=True) + network = shared.etcd_client.get( + join_path(settings['etcd']['network_prefix'], network_owner, network_name), value_in_json=True + ) return network -def delete_vm_network(vm_entry): - try: - for network in vm_entry.network: - network_name = network[0] - tap_mac = network[1] - tap_id = network[2] - - delete_network_interface('tap{}'.format(tap_id)) - - owners_vms = shared.vm_pool.by_owner(vm_entry.owner) - owners_running_vms = shared.vm_pool.by_status(VMStatus.running, - _vms=owners_vms) - - networks = map( - lambda n: n[0], map(lambda vm: vm.network, owners_running_vms) - ) - networks_in_use_by_user_vms = [vm[0] for vm in networks] - if network_name not in networks_in_use_by_user_vms: - network_entry = resolve_network(network[0], vm_entry.owner) - if network_entry: - network_type = network_entry.value["type"] - network_id = network_entry.value["id"] - if network_type == "vxlan": - delete_network_interface('br{}'.format(network_id)) - delete_network_interface('vxlan{}'.format(network_id)) - except Exception: - logger.exception("Exception in network interface deletion") - - def create_vxlan_br_tap(_id, _dev, tap_id, ip=None): network_script_base = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'network') vxlan = create_dev(script=os.path.join(network_script_base, 'create-vxlan.sh'), @@ -377,10 +222,12 @@ def update_radvd_conf(all_networks): ) for net in networks if networks.get(net) ] - with open('/etc/radvd.conf', 'w') as radvd_conf: radvd_conf.writelines(content) try: sp.check_output(['systemctl', 'restart', 'radvd']) - except Exception: - sp.check_output(['service', 'radvd', 'restart']) + except sp.CalledProcessError: + try: + sp.check_output(['service', 'radvd', 'restart']) + except sp.CalledProcessError as err: + raise err.__class__('Cannot start/restart radvd service', err.cmd) from err diff --git a/ucloud/scheduler/helper.py b/ucloud/scheduler/helper.py index 643e8e9..0e9ef73 100755 --- a/ucloud/scheduler/helper.py +++ b/ucloud/scheduler/helper.py @@ -95,7 +95,7 @@ def dead_host_mitigation(dead_hosts_keys): vms_hosted_on_dead_host = shared.vm_pool.by_host(host_key) for vm in vms_hosted_on_dead_host: - vm.declare_killed() + vm.status = 'UNKNOWN' shared.vm_pool.put(vm) shared.host_pool.put(host) diff --git a/ucloud/scheduler/main.py b/ucloud/scheduler/main.py index 3412545..d91979f 100755 --- a/ucloud/scheduler/main.py +++ b/ucloud/scheduler/main.py @@ -56,35 +56,14 @@ def main(): continue shared.etcd_client.client.delete(request_entry.key) # consume Request - # If the Request is about a VM which is labelled as "migration" - # and has a destination - if hasattr(request_entry, "migration") and request_entry.migration \ - and hasattr(request_entry, "destination") and request_entry.destination: - try: - get_suitable_host(vm_specs=vm_entry.specs, - hosts=[shared.host_pool.get(request_entry.destination)]) - except NoSuitableHostFound: - logger.info("Requested destination host doesn't have enough capacity" - "to hold %s" % vm_entry.uuid) - else: - r = RequestEntry.from_scratch(type=RequestType.InitVMMigration, - uuid=request_entry.uuid, - destination=request_entry.destination, - request_prefix=settings['etcd']['request_prefix']) - shared.request_pool.put(r) + try: + assign_host(vm_entry) + except NoSuitableHostFound: + vm_entry.add_log("Can't schedule VM. No Resource Left.") + shared.vm_pool.put(vm_entry) - # If the Request is about a VM that just want to get started/created - else: - # assign_host only returns None when we couldn't be able to assign - # a host to a VM because of resource constraints - try: - assign_host(vm_entry) - except NoSuitableHostFound: - vm_entry.add_log("Can't schedule VM. No Resource Left.") - shared.vm_pool.put(vm_entry) - - pending_vms.append(vm_entry) - logger.info("No Resource Left. Emailing admin....") + pending_vms.append(vm_entry) + logger.info("No Resource Left. Emailing admin....") if __name__ == "__main__": diff --git a/ucloud/vmm/__init__.py b/ucloud/vmm/__init__.py new file mode 100644 index 0000000..1291da4 --- /dev/null +++ b/ucloud/vmm/__init__.py @@ -0,0 +1,181 @@ +import os +import subprocess as sp +import logging +import socket +import json +import tempfile +import time + +from contextlib import suppress +from multiprocessing import Process +from os.path import join as join_path +from os.path import isdir + +logger = logging.getLogger(__name__) + + +class VMQMPHandles: + def __init__(self, path): + self.path = path + self.sock = socket.socket(socket.AF_UNIX) + self.file = self.sock.makefile() + + def __enter__(self): + self.sock.connect(self.path) + + # eat qmp greetings + self.file.readline() + + # init qmp + self.sock.sendall(b'{ "execute": "qmp_capabilities" }') + self.file.readline() + + return self.sock, self.file + + def __exit__(self, exc_type, exc_val, exc_tb): + self.file.close() + self.sock.close() + + if exc_type: + logger.error('Couldn\'t get handle for VM.', exc_type, exc_val, exc_tb) + raise exc_type("Couldn't get handle for VM.") from exc_type + + +class TransferVM(Process): + def __init__(self, src_uuid, dest_uuid, host, socket_dir): + self.src_uuid = src_uuid + self.dest_uuid = dest_uuid + self.host = host + self.src_sock_path = os.path.join(socket_dir, self.src_uuid) + self.dest_sock_path = os.path.join(socket_dir, self.dest_uuid) + + super().__init__() + + def run(self): + with suppress(FileNotFoundError): + os.remove(self.src_sock_path) + + command = ['ssh', '-nNT', '-L', '{}:{}'.format(self.src_sock_path, self.dest_sock_path), + 'root@{}'.format(self.host)] + + try: + p = sp.Popen(command) + except Exception as e: + logger.error('Couldn\' forward unix socks over ssh.', exc_info=e) + else: + time.sleep(2) + vmm = VMM() + logger.debug('Executing: ssh forwarding command: %s', command) + vmm.execute_command(self.src_uuid, command='migrate', + arguments={'uri': 'unix:{}'.format(self.src_sock_path)}) + + while p.poll() is None: + success, output = vmm.execute_command(self.src_uuid, command='query-migrate') + if success: + status = output['return']['status'] + if status != 'active': + print('Migration Status: ', status) + return + else: + print('Migration Status: ', status) + else: + return + time.sleep(0.2) + + +class VMM: + # Virtual Machine Manager + def __init__(self, qemu_path='/usr/bin/qemu-system-x86_64', + vmm_backend=os.path.expanduser('~/ucloud/vmm/')): + self.qemu_path = qemu_path + self.vmm_backend = vmm_backend + self.socket_dir = os.path.join(self.vmm_backend, 'sock') + + def is_running(self, uuid): + sock_path = os.path.join(self.vmm_backend, uuid) + try: + sock = socket.socket(socket.AF_UNIX) + sock.connect(sock_path) + recv = sock.recv(4096) + except Exception as err: + # unix sock doesn't exists or it is closed + logger.info('VM %s sock either don\' exists or it is closed.', uuid, + 'It mean VM is stopped.', exc_info=err) + else: + # if we receive greetings from qmp it mean VM is running + if len(recv) > 0: + return True + + with suppress(FileNotFoundError): + os.remove(sock_path) + + return False + + def start(self, *args, uuid, migration=False): + # start --> sucess? + migration_args = () + if migration: + migration_args = ('-incoming', 'unix:{}'.format(os.path.join(self.socket_dir, uuid))) + + if self.is_running(uuid): + logger.warning('Cannot start VM. It is already running.') + else: + qmp_arg = ('-qmp', 'unix:{}/{},server,nowait'.format(self.vmm_backend, uuid)) + vnc_arg = ('-vnc', 'unix:{}'.format(tempfile.NamedTemporaryFile().name)) + + command = [self.qemu_path, *args, *qmp_arg, *migration_args, *vnc_arg, '-daemonize'] + try: + sp.check_output(command, stderr=sp.PIPE) + except sp.CalledProcessError as err: + logger.exception('Error occurred while starting VM.\nDetail %s', err.stderr.decode('utf-8')) + else: + time.sleep(2) + + def execute_command(self, uuid, command, **kwargs): + # execute_command -> sucess?, output + try: + with VMQMPHandles(os.path.join(self.vmm_backend, uuid)) as (sock_handle, file_handle): + command_to_execute = { + 'execute': command, + **kwargs + } + sock_handle.sendall(json.dumps(command_to_execute).encode('utf-8')) + output = file_handle.readline() + except Exception as err: + logger.exception('Error occurred while executing command and getting valid output from qmp') + else: + try: + output = json.loads(output) + except: + logger.exception('QMP Output isn\'t valid JSON. %s', output) + else: + return 'return' in output, output + return False, None + + def stop(self, uuid): + success, output = self.execute_command(command='quit', uuid=uuid) + return success + + def get_status(self, uuid): + success, output = self.execute_command(command='query-status', uuid=uuid) + if success: + return output['return']['status'] + else: + return 'STOPPED' + + def discover(self): + vms = [ + uuid for uuid in os.listdir(self.vmm_backend) + if not isdir(join_path(self.vmm_backend, uuid)) + ] + return vms + + def get_vnc(self, uuid): + success, output = self.execute_command(uuid, command='query-vnc') + if success: + return output['return']['service'] + return None + + def transfer(self, src_uuid, dest_uuid, host): + p = TransferVM(src_uuid, dest_uuid, socket_dir=self.socket_dir, host=host) + p.start() From 808271f3e07938e7886273e470b4a72e504848c5 Mon Sep 17 00:00:00 2001 From: meow Date: Sat, 28 Dec 2019 16:35:55 +0500 Subject: [PATCH 087/284] Return nice message when etcd section is missing --- ucloud/configure/main.py | 2 -- ucloud/settings/__init__.py | 9 ++++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/ucloud/configure/main.py b/ucloud/configure/main.py index 71e07a1..e4770d9 100644 --- a/ucloud/configure/main.py +++ b/ucloud/configure/main.py @@ -1,5 +1,3 @@ -import argparse -import sys import os from ucloud.settings import settings diff --git a/ucloud/settings/__init__.py b/ucloud/settings/__init__.py index b651aa2..e589485 100644 --- a/ucloud/settings/__init__.py +++ b/ucloud/settings/__init__.py @@ -30,7 +30,10 @@ class Settings(object): self.config_key = config_key self.read_internal_values() - self.config_parser.read(self.config_file) + try: + self.config_parser.read(self.config_file) + except Exception as err: + logger.error('%s', err) def get_etcd_client(self): args = tuple() @@ -39,8 +42,8 @@ class Settings(object): 'host': self.config_parser.get('etcd', 'url'), 'port': self.config_parser.get('etcd', 'port'), 'ca_cert': self.config_parser.get('etcd', 'ca_cert'), - 'cert_cert': self.config_parser.get('etcd','cert_cert'), - 'cert_key': self.config_parser.get('etcd','cert_key') + 'cert_cert': self.config_parser.get('etcd', 'cert_cert'), + 'cert_key': self.config_parser.get('etcd', 'cert_key') } except configparser.Error as err: raise configparser.Error('{} in config file {}'.format(err.message, self.config_file)) from err From f980cdb4649f5d805bd939eb9596456e916f8768 Mon Sep 17 00:00:00 2001 From: meow Date: Sun, 29 Dec 2019 23:14:39 +0500 Subject: [PATCH 088/284] Better error handling, Efforts to run non-root with occasional sudo --- scripts/ucloud | 39 +++++++++++++++++---------------- ucloud/common/etcd_wrapper.py | 14 +++++++----- ucloud/common/logging.py | 11 ++++++---- ucloud/common/network.py | 15 ++++++++----- ucloud/host/main.py | 6 ++--- ucloud/settings/__init__.py | 11 ++++++++-- ucloud/vmm/__init__.py | 41 +++++++++++++++++++++++++++++------ 7 files changed, 90 insertions(+), 47 deletions(-) diff --git a/scripts/ucloud b/scripts/ucloud index 3ddbb5a..9d05118 100755 --- a/scripts/ucloud +++ b/scripts/ucloud @@ -1,5 +1,4 @@ #!/usr/bin/env python3 - import argparse import logging import importlib @@ -8,13 +7,12 @@ import sys from logging.handlers import SysLogHandler -from ucloud.configure.main import configure_parser from ucloud.common.logging import NoTracebackStreamHandler +from ucloud.configure.main import configure_parser def exception_hook(exc_type, exc_value, exc_traceback): - logger = logging.getLogger(__name__) - logger.error( + logging.getLogger(__name__).error( 'Uncaught exception', exc_info=(exc_type, exc_value, exc_traceback) ) @@ -22,7 +20,25 @@ def exception_hook(exc_type, exc_value, exc_traceback): sys.excepthook = exception_hook + if __name__ == '__main__': + # Setting up root logger + logger = logging.getLogger() + logger.setLevel(logging.INFO) + + syslog_handler = SysLogHandler(address='/dev/log') + syslog_handler.setLevel(logging.DEBUG) + syslog_formatter = logging.Formatter('%(pathname)s:%(lineno)d -- %(levelname)-8s %(message)s') + syslog_handler.setFormatter(syslog_formatter) + + stream_handler = NoTracebackStreamHandler() + stream_handler.setLevel(logging.INFO) + stream_formatter = logging.Formatter('%(message)s') + stream_handler.setFormatter(stream_formatter) + + logger.addHandler(syslog_handler) + logger.addHandler(stream_handler) + arg_parser = argparse.ArgumentParser() subparsers = arg_parser.add_subparsers(dest="command") @@ -46,21 +62,6 @@ if __name__ == '__main__': if not args.command: arg_parser.print_help() else: - # Setting up root logger - logger = logging.getLogger('ucloud') - - syslog_handler = SysLogHandler(address='/dev/log') - syslog_handler.setLevel(logging.DEBUG) - syslog_formatter = logging.Formatter('%(pathname)s:%(lineno)d -- %(levelname)-8s %(message)s') - syslog_handler.setFormatter(syslog_formatter) - - stream_handler = NoTracebackStreamHandler() - stream_handler.setLevel(logging.WARNING) - stream_formatter = logging.Formatter('%(message)s') - stream_handler.setFormatter(stream_formatter) - - logger.addHandler(syslog_handler) - logger.addHandler(stream_handler) # if we start etcd in seperate process with default settings # i.e inheriting few things from parent process etcd3 module diff --git a/ucloud/common/etcd_wrapper.py b/ucloud/common/etcd_wrapper.py index eecf4c7..5f464e1 100644 --- a/ucloud/common/etcd_wrapper.py +++ b/ucloud/common/etcd_wrapper.py @@ -29,15 +29,16 @@ def readable_errors(func): try: return func(*args, **kwargs) except etcd3.exceptions.ConnectionFailedError as err: - raise etcd3.exceptions.ConnectionFailedError('etcd connection failed') from err + raise etcd3.exceptions.ConnectionFailedError('etcd connection failed.') from err except etcd3.exceptions.ConnectionTimeoutError as err: - raise etcd3.exceptions.ConnectionTimeoutError('etcd connection timeout') from err + raise etcd3.exceptions.ConnectionTimeoutError('etcd connection timeout.') from err except Exception: - logger.exception('Some etcd error occurred') + logger.exception('Some etcd error occured. See syslog for details.') return wrapper class Etcd3Wrapper: + @readable_errors def __init__(self, *args, **kwargs): self.client = etcd3.client(*args, **kwargs) @@ -77,9 +78,10 @@ class Etcd3Wrapper: event_queue = queue.Queue() def add_event_to_queue(event): - for e in event.events: - if e.value: - event_queue.put(EtcdEntry(e, e.value, value_in_json=value_in_json)) + if hasattr(event, 'events'): + for e in event.events: + if e.value: + event_queue.put(EtcdEntry(e, e.value, value_in_json=value_in_json)) self.client.add_watch_prefix_callback(key, add_event_to_queue) diff --git a/ucloud/common/logging.py b/ucloud/common/logging.py index 945f473..ba1e59d 100644 --- a/ucloud/common/logging.py +++ b/ucloud/common/logging.py @@ -7,14 +7,17 @@ class NoTracebackStreamHandler(logging.StreamHandler): info, cache = record.exc_info, record.exc_text record.exc_info, record.exc_text = None, None - if record.levelname == 'WARNING': - color = colorama.Fore.YELLOW - elif record.levelname in ['ERROR', 'EXCEPTION']: + if record.levelname in ['WARNING', 'WARN']: + color = colorama.Fore.LIGHTYELLOW_EX + elif record.levelname == 'ERROR': color = colorama.Fore.LIGHTRED_EX elif record.levelname == 'INFO': - color = colorama.Fore.LIGHTBLUE_EX + color = colorama.Fore.LIGHTGREEN_EX + elif record.levelname == 'CRITICAL': + color = colorama.Fore.LIGHTCYAN_EX else: color = colorama.Fore.WHITE + try: print(color, end='', flush=True) super().handle(record) diff --git a/ucloud/common/network.py b/ucloud/common/network.py index 629e92a..1503446 100644 --- a/ucloud/common/network.py +++ b/ucloud/common/network.py @@ -30,14 +30,14 @@ def generate_mac(uaa=False, multicast=False, oui=None, separator=':', byte_fmt=' def create_dev(script, _id, dev, ip=None): - command = [script, str(_id), dev] + command = ['sudo', '-p', 'Enter password to create network devices for vm: ', + script, str(_id), dev] if ip: command.append(ip) try: output = sp.check_output(command, stderr=sp.PIPE) - except Exception as e: + except Exception: logger.exception('Creation of interface %s failed.', dev) - print(e) return None else: return output.decode('utf-8').strip() @@ -45,9 +45,14 @@ def create_dev(script, _id, dev, ip=None): def delete_network_interface(iface): try: - sp.check_output(['ip', 'link', 'del', iface]) + sp.check_output( + [ + 'sudo', '-p', 'Enter password to remove {} network device: '.format(iface), + 'ip', 'link', 'del', iface + ], stderr=sp.PIPE + ) except Exception: - logger.exception('Interface Deletion failed') + logger.exception('Interface %s Deletion failed', iface) def find_free_port(): diff --git a/ucloud/host/main.py b/ucloud/host/main.py index 8a7dbe7..b5aeee3 100755 --- a/ucloud/host/main.py +++ b/ucloud/host/main.py @@ -14,10 +14,8 @@ from . import virtualmachine, logger def update_heartbeat(hostname): """Update Last HeartBeat Time for :param hostname: in etcd""" - host_pool = shared.host_pool this_host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) - while True: this_host.update_heartbeat() host_pool.put(this_host) @@ -43,7 +41,7 @@ def main(hostname): heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(hostname,)) heartbeat_updating_process.start() except Exception as e: - raise e.__class__('ucloud-host heartbeat updating mechanism is not working') from e + raise Exception('ucloud-host heartbeat updating mechanism is not working') from e for events_iterator in [ shared.etcd_client.get_prefix(settings['etcd']['request_prefix'], value_in_json=True), @@ -87,7 +85,7 @@ def main(hostname): if __name__ == "__main__": argparser = argparse.ArgumentParser() - argparser.add_argument("hostname", help="Name of this host. e.g /v1/host/1") + argparser.add_argument("hostname", help="Name of this host. e.g uncloud1.ungleich.ch") args = argparser.parse_args() mp.set_start_method('spawn') main(args.hostname) diff --git a/ucloud/settings/__init__.py b/ucloud/settings/__init__.py index e589485..f9b358e 100644 --- a/ucloud/settings/__init__.py +++ b/ucloud/settings/__init__.py @@ -47,8 +47,15 @@ class Settings(object): } except configparser.Error as err: raise configparser.Error('{} in config file {}'.format(err.message, self.config_file)) from err - - return Etcd3Wrapper(*args, **kwargs) + else: + try: + wrapper = Etcd3Wrapper(*args, **kwargs) + except Exception as err: + logger.error('etcd connection not successfull. Please check your config file.' + '\nDetails: %s\netcd connection parameters: %s', err, kwargs) + sys.exit(1) + else: + return wrapper def read_internal_values(self): self.config_parser.read_dict({ diff --git a/ucloud/vmm/__init__.py b/ucloud/vmm/__init__.py index 1291da4..f85d7a3 100644 --- a/ucloud/vmm/__init__.py +++ b/ucloud/vmm/__init__.py @@ -91,6 +91,14 @@ class VMM: self.vmm_backend = vmm_backend self.socket_dir = os.path.join(self.vmm_backend, 'sock') + if not os.path.isdir(self.vmm_backend): + logger.info('{} does not exists. Creating it...'.format(self.vmm_backend)) + os.makedirs(self.vmm_backend, exist_ok=True) + + if not os.path.isdir(self.socket_dir): + logger.info('{} does not exists. Creating it...'.format(self.socket_dir)) + os.makedirs(self.socket_dir, exist_ok=True) + def is_running(self, uuid): sock_path = os.path.join(self.vmm_backend, uuid) try: @@ -99,8 +107,8 @@ class VMM: recv = sock.recv(4096) except Exception as err: # unix sock doesn't exists or it is closed - logger.info('VM %s sock either don\' exists or it is closed.', uuid, - 'It mean VM is stopped.', exc_info=err) + logger.debug('VM {} sock either don\' exists or it is closed. It mean VM is stopped.'.format(uuid), + exc_info=err) else: # if we receive greetings from qmp it mean VM is running if len(recv) > 0: @@ -120,16 +128,34 @@ class VMM: if self.is_running(uuid): logger.warning('Cannot start VM. It is already running.') else: - qmp_arg = ('-qmp', 'unix:{}/{},server,nowait'.format(self.vmm_backend, uuid)) + qmp_arg = ('-qmp', 'unix:{},server,nowait'.format(join_path(self.vmm_backend, uuid))) vnc_arg = ('-vnc', 'unix:{}'.format(tempfile.NamedTemporaryFile().name)) - command = [self.qemu_path, *args, *qmp_arg, *migration_args, *vnc_arg, '-daemonize'] + command = ['sudo', '-p', 'Enter password to start VM {}: '.format(uuid), + self.qemu_path, *args, *qmp_arg, *migration_args, *vnc_arg, '-daemonize'] try: sp.check_output(command, stderr=sp.PIPE) except sp.CalledProcessError as err: logger.exception('Error occurred while starting VM.\nDetail %s', err.stderr.decode('utf-8')) else: - time.sleep(2) + with suppress(sp.CalledProcessError): + sp.check_output([ + 'sudo', '-p', + 'Enter password to correct permission for uncloud-vmm\'s directory', + 'chmod', '-R', 'o=rwx,g=rwx', self.vmm_backend + ]) + + # TODO: Find some good way to check whether the virtual machine is up and + # running without relying on non-guarenteed ways. + for _ in range(10): + time.sleep(2) + status = self.get_status(uuid) + if status in ['running', 'inmigrate']: + return status + logger.warning('Timeout on VM\'s status. Shutting down VM %s', uuid) + self.stop(uuid) + # TODO: What should we do more. VM can still continue to run in background. + # If we have pid of vm we can kill it using OS. def execute_command(self, uuid, command, **kwargs): # execute_command -> sucess?, output @@ -141,12 +167,12 @@ class VMM: } sock_handle.sendall(json.dumps(command_to_execute).encode('utf-8')) output = file_handle.readline() - except Exception as err: + except Exception: logger.exception('Error occurred while executing command and getting valid output from qmp') else: try: output = json.loads(output) - except: + except Exception: logger.exception('QMP Output isn\'t valid JSON. %s', output) else: return 'return' in output, output @@ -161,6 +187,7 @@ class VMM: if success: return output['return']['status'] else: + # TODO: Think about this for a little more return 'STOPPED' def discover(self): From 29e938dc74bfea7edd0c69963efe25ec844527cb Mon Sep 17 00:00:00 2001 From: meow Date: Sun, 29 Dec 2019 23:48:04 +0500 Subject: [PATCH 089/284] Destination Host of VM during migration now notify Source host of exact socket path --- ucloud/common/request.py | 1 + ucloud/host/main.py | 3 ++- ucloud/host/virtualmachine.py | 6 ++++-- ucloud/vmm/__init__.py | 15 ++++++++++----- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/ucloud/common/request.py b/ucloud/common/request.py index 2d9be44..5705eed 100644 --- a/ucloud/common/request.py +++ b/ucloud/common/request.py @@ -19,6 +19,7 @@ class RequestType: class RequestEntry(SpecificEtcdEntryBase): def __init__(self, e): + self.destination_sock_path = None self.destination_host_key = None self.type = None # type: str self.migration = None # type: bool diff --git a/ucloud/host/main.py b/ucloud/host/main.py index b5aeee3..904f26c 100755 --- a/ucloud/host/main.py +++ b/ucloud/host/main.py @@ -76,7 +76,8 @@ def main(hostname): elif request_event.type == RequestType.TransferVM: host = host_pool.get(request_event.destination_host_key) if host: - vm.migrate(destination=host.hostname) + vm.migrate(destination_host=host.hostname, + destination_sock_path=request_event.destination_sock_path) else: logger.error('Host %s not found!', request_event.destination_host_key) else: diff --git a/ucloud/host/virtualmachine.py b/ucloud/host/virtualmachine.py index 6d25205..db0f7b8 100755 --- a/ucloud/host/virtualmachine.py +++ b/ucloud/host/virtualmachine.py @@ -78,6 +78,7 @@ class VM: type=RequestType.TransferVM, # Transfer VM hostname=self.host_key, # Which VM should get this request. It is source host uuid=self.uuid, # uuid of VM + destination_sock_path=join_path(self.vmm.socket_dir, self.uuid), destination_host_key=destination_host_key, # Where source host transfer VM request_prefix=settings['etcd']['request_prefix'] ) @@ -94,8 +95,9 @@ class VM: declare_stopped(self.vm) self.sync() - def migrate(self, destination): - self.vmm.transfer(src_uuid=self.uuid, dest_uuid=self.uuid, host=destination) + def migrate(self, destination_host, destination_sock_path): + self.vmm.transfer(src_uuid=self.uuid, destination_sock_path=destination_sock_path, + host=destination_host) def create_network_dev(self): command = '' diff --git a/ucloud/vmm/__init__.py b/ucloud/vmm/__init__.py index f85d7a3..9f9f5f9 100644 --- a/ucloud/vmm/__init__.py +++ b/ucloud/vmm/__init__.py @@ -42,12 +42,11 @@ class VMQMPHandles: class TransferVM(Process): - def __init__(self, src_uuid, dest_uuid, host, socket_dir): + def __init__(self, src_uuid, dest_sock_path, host, socket_dir): self.src_uuid = src_uuid - self.dest_uuid = dest_uuid self.host = host self.src_sock_path = os.path.join(socket_dir, self.src_uuid) - self.dest_sock_path = os.path.join(socket_dir, self.dest_uuid) + self.dest_sock_path = dest_sock_path super().__init__() @@ -203,6 +202,12 @@ class VMM: return output['return']['service'] return None - def transfer(self, src_uuid, dest_uuid, host): - p = TransferVM(src_uuid, dest_uuid, socket_dir=self.socket_dir, host=host) + def transfer(self, src_uuid, destination_sock_path, host): + p = TransferVM(src_uuid, destination_sock_path, socket_dir=self.socket_dir, host=host) p.start() + + # TODO: the following method should clean things that went wrong + # e.g If VM migration fails or didn't start for long time + # i.e 15 minutes we should stop the waiting VM. + def maintenace(self): + pass \ No newline at end of file From 9bdf4d2180a0748ec4ffd5cf83b104e99152ac08 Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 30 Dec 2019 14:35:07 +0500 Subject: [PATCH 090/284] Shutdown Source VM (PAUSED) on successfull migration + blackened all .py files --- setup.py | 71 +++-- ucloud/api/common_fields.py | 16 +- ucloud/api/create_image_store.py | 5 +- ucloud/api/helper.py | 44 ++- ucloud/api/main.py | 178 ++++++++---- ucloud/api/schemas.py | 275 ++++++++++++------ ucloud/common/etcd_wrapper.py | 46 ++- ucloud/common/host.py | 4 +- ucloud/common/logging.py | 12 +- ucloud/common/network.py | 40 ++- ucloud/common/request.py | 8 +- ucloud/common/schemas.py | 10 +- ucloud/common/storage_handlers.py | 53 ++-- ucloud/common/vm.py | 11 +- ucloud/configure/main.py | 66 +++-- ucloud/docs/source/conf.py | 14 +- ucloud/filescanner/main.py | 15 +- ucloud/host/main.py | 82 ++++-- ucloud/host/qmp/__init__.py | 173 ++++++----- ucloud/host/qmp/qmp.py | 22 +- ucloud/host/virtualmachine.py | 220 +++++++++----- ucloud/imagescanner/main.py | 83 ++++-- ucloud/metadata/main.py | 48 +-- ucloud/scheduler/__init__.py | 2 +- ucloud/scheduler/helper.py | 50 +++- ucloud/scheduler/main.py | 49 +++- ucloud/scheduler/tests/test_basics.py | 41 ++- .../tests/test_dead_host_mechanism.py | 30 +- ucloud/settings/__init__.py | 82 ++++-- ucloud/shared/__init__.py | 10 +- ucloud/vmm/__init__.py | 185 ++++++++---- 31 files changed, 1307 insertions(+), 638 deletions(-) diff --git a/setup.py b/setup.py index 51d21b8..956656b 100644 --- a/setup.py +++ b/setup.py @@ -7,41 +7,48 @@ with open("README.md", "r") as fh: try: import ucloud.version + version = ucloud.version.VERSION except: import subprocess - c = subprocess.check_output(['git', 'describe']) + + c = subprocess.check_output(["git", "describe"]) version = c.decode("utf-8").strip() -setup(name='ucloud', - version=version, - description='All ucloud server components.', - url='https://code.ungleich.ch/ucloud/ucloud', - long_description=long_description, - long_description_content_type='text/markdown', - classifiers=[ - 'Development Status :: 3 - Alpha', - 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', - 'Programming Language :: Python :: 3' - ], - author='ungleich', - author_email='technik@ungleich.ch', - packages=find_packages(), - install_requires=[ - 'requests', - 'Flask>=1.1.1', - 'flask-restful', - 'bitmath', - 'pyotp', - 'sshtunnel', - 'sphinx', - 'pynetbox', - 'colorama', - 'sphinx-rtd-theme', - 'etcd3 @ https://github.com/kragniz/python-etcd3/tarball/master#egg=etcd3', - 'werkzeug', 'marshmallow' - ], - scripts=['scripts/ucloud'], - data_files=[(os.path.expanduser('~/ucloud/'), ['conf/ucloud.conf'])], - zip_safe=False) +setup( + name="ucloud", + version=version, + description="All ucloud server components.", + url="https://code.ungleich.ch/ucloud/ucloud", + long_description=long_description, + long_description_content_type="text/markdown", + classifiers=[ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Programming Language :: Python :: 3", + ], + author="ungleich", + author_email="technik@ungleich.ch", + packages=find_packages(), + install_requires=[ + "requests", + "Flask>=1.1.1", + "flask-restful", + "bitmath", + "pyotp", + "sshtunnel", + "sphinx", + "pynetbox", + "colorama", + "sphinx-rtd-theme", + "etcd3 @ https://github.com/kragniz/python-etcd3/tarball/master#egg=etcd3", + "werkzeug", + "marshmallow", + ], + scripts=["scripts/ucloud"], + data_files=[ + (os.path.expanduser("~/ucloud/"), ["conf/ucloud.conf"]) + ], + zip_safe=False, +) diff --git a/ucloud/api/common_fields.py b/ucloud/api/common_fields.py index a793d26..93f9e06 100755 --- a/ucloud/api/common_fields.py +++ b/ucloud/api/common_fields.py @@ -20,12 +20,16 @@ class Field: def is_valid(self): if self.value == KeyError: - self.add_error("'{}' field is a required field".format(self.name)) + self.add_error( + "'{}' field is a required field".format(self.name) + ) else: if isinstance(self.value, Optional): pass elif not isinstance(self.value, self.type): - self.add_error("Incorrect Type for '{}' field".format(self.name)) + self.add_error( + "Incorrect Type for '{}' field".format(self.name) + ) else: self.validation() @@ -49,6 +53,10 @@ class VmUUIDField(Field): self.validation = self.vm_uuid_validation def vm_uuid_validation(self): - r = shared.etcd_client.get(os.path.join(settings['etcd']['vm_prefix'], self.uuid)) + r = shared.etcd_client.get( + os.path.join(settings["etcd"]["vm_prefix"], self.uuid) + ) if not r: - self.add_error("VM with uuid {} does not exists".format(self.uuid)) + self.add_error( + "VM with uuid {} does not exists".format(self.uuid) + ) diff --git a/ucloud/api/create_image_store.py b/ucloud/api/create_image_store.py index 978a182..a433ce3 100755 --- a/ucloud/api/create_image_store.py +++ b/ucloud/api/create_image_store.py @@ -14,4 +14,7 @@ data = { "attributes": {"list": [], "key": [], "pool": "images"}, } -shared.etcd_client.put(os.path.join(settings['etcd']['image_store_prefix'], uuid4().hex), json.dumps(data)) +shared.etcd_client.put( + os.path.join(settings["etcd"]["image_store_prefix"], uuid4().hex), + json.dumps(data), +) diff --git a/ucloud/api/helper.py b/ucloud/api/helper.py index e275e46..a77a151 100755 --- a/ucloud/api/helper.py +++ b/ucloud/api/helper.py @@ -16,21 +16,23 @@ logger = logging.getLogger(__name__) def check_otp(name, realm, token): try: data = { - "auth_name": settings['otp']['auth_name'], - "auth_token": TOTP(settings['otp']['auth_seed']).now(), - "auth_realm": settings['otp']['auth_realm'], + "auth_name": settings["otp"]["auth_name"], + "auth_token": TOTP(settings["otp"]["auth_seed"]).now(), + "auth_realm": settings["otp"]["auth_realm"], "name": name, "realm": realm, "token": token, } except binascii.Error as err: logger.error( - "Cannot compute OTP for seed: {}".format(settings['otp']['auth_seed']) + "Cannot compute OTP for seed: {}".format( + settings["otp"]["auth_seed"] + ) ) return 400 response = requests.post( - settings['otp']['verification_controller_url'], json=data + settings["otp"]["verification_controller_url"], json=data ) return response.status_code @@ -43,7 +45,8 @@ def resolve_vm_name(name, owner): """ result = next( filter( - lambda vm: vm.value["owner"] == owner and vm.value["name"] == name, + lambda vm: vm.value["owner"] == owner + and vm.value["name"] == name, shared.vm_pool.vms, ), None, @@ -80,18 +83,27 @@ def resolve_image_name(name, etcd_client): """ store_name, image_name = store_name_and_image_name except Exception: - raise ValueError("Image name not in correct format i.e {store_name}:{image_name}") + raise ValueError( + "Image name not in correct format i.e {store_name}:{image_name}" + ) - images = etcd_client.get_prefix(settings['etcd']['image_prefix'], value_in_json=True) + images = etcd_client.get_prefix( + settings["etcd"]["image_prefix"], value_in_json=True + ) # Try to find image with name == image_name and store_name == store_name try: - image = next(filter(lambda im: im.value['name'] == image_name - and im.value['store_name'] == store_name, images)) + image = next( + filter( + lambda im: im.value["name"] == image_name + and im.value["store_name"] == store_name, + images, + ) + ) except StopIteration: raise KeyError("No image with name {} found.".format(name)) else: - image_uuid = image.key.split('/')[-1] + image_uuid = image.key.split("/")[-1] return image_uuid @@ -100,7 +112,9 @@ def random_bytes(num=6): return [random.randrange(256) for _ in range(num)] -def generate_mac(uaa=False, multicast=False, oui=None, separator=':', byte_fmt='%02x'): +def generate_mac( + uaa=False, multicast=False, oui=None, separator=":", byte_fmt="%02x" +): mac = random_bytes() if oui: if type(oui) == str: @@ -131,7 +145,9 @@ def get_ip_addr(mac_address, device): and is connected/neighbor of arg:device """ try: - output = sp.check_output(['ip', '-6', 'neigh', 'show', 'dev', device], stderr=sp.PIPE) + output = sp.check_output( + ["ip", "-6", "neigh", "show", "dev", device], stderr=sp.PIPE + ) except sp.CalledProcessError: return None else: @@ -160,7 +176,7 @@ def mac2ipv6(mac, prefix): # format output ipv6_parts = [str(0)] * 4 for i in range(0, len(parts), 2): - ipv6_parts.append("".join(parts[i:i + 2])) + ipv6_parts.append("".join(parts[i : i + 2])) lower_part = ipaddress.IPv6Address(":".join(ipv6_parts)) prefix = ipaddress.IPv6Address(prefix) diff --git a/ucloud/api/main.py b/ucloud/api/main.py index 91cbead..c63babf 100644 --- a/ucloud/api/main.py +++ b/ucloud/api/main.py @@ -43,7 +43,7 @@ def handle_exception(e): return e # now you're handling non-HTTP exceptions only - return {'message': 'Server Error'}, 500 + return {"message": "Server Error"}, 500 class CreateVM(Resource): @@ -55,7 +55,7 @@ class CreateVM(Resource): validator = schemas.CreateVMSchema(data) if validator.is_valid(): vm_uuid = uuid4().hex - vm_key = join_path(settings['etcd']['vm_prefix'], vm_uuid) + vm_key = join_path(settings["etcd"]["vm_prefix"], vm_uuid) specs = { "cpu": validator.specs["cpu"], "ram": validator.specs["ram"], @@ -63,8 +63,12 @@ class CreateVM(Resource): "hdd": validator.specs["hdd"], } macs = [generate_mac() for _ in range(len(data["network"]))] - tap_ids = [counters.increment_etcd_counter(shared.etcd_client, "/v1/counter/tap") - for _ in range(len(data["network"]))] + tap_ids = [ + counters.increment_etcd_counter( + shared.etcd_client, "/v1/counter/tap" + ) + for _ in range(len(data["network"])) + ] vm_entry = { "name": data["vm_name"], "owner": data["name"], @@ -77,14 +81,15 @@ class CreateVM(Resource): "vnc_socket": "", "network": list(zip(data["network"], macs, tap_ids)), "metadata": {"ssh-keys": []}, - "in_migration": False + "in_migration": False, } shared.etcd_client.put(vm_key, vm_entry, value_in_json=True) # Create ScheduleVM Request r = RequestEntry.from_scratch( - type=RequestType.ScheduleVM, uuid=vm_uuid, - request_prefix=settings['etcd']['request_prefix'] + type=RequestType.ScheduleVM, + uuid=vm_uuid, + request_prefix=settings["etcd"]["request_prefix"], ) shared.request_pool.put(r) @@ -99,7 +104,7 @@ class VmStatus(Resource): validator = schemas.VMStatusSchema(data) if validator.is_valid(): vm = shared.vm_pool.get( - join_path(settings['etcd']['vm_prefix'], data["uuid"]) + join_path(settings["etcd"]["vm_prefix"], data["uuid"]) ) vm_value = vm.value.copy() vm_value["ip"] = [] @@ -107,13 +112,15 @@ class VmStatus(Resource): network_name, mac, tap = network_mac_and_tap network = shared.etcd_client.get( join_path( - settings['etcd']['network_prefix'], + settings["etcd"]["network_prefix"], data["name"], network_name, ), value_in_json=True, ) - ipv6_addr = network.value.get("ipv6").split("::")[0] + "::" + ipv6_addr = ( + network.value.get("ipv6").split("::")[0] + "::" + ) vm_value["ip"].append(mac2ipv6(mac, ipv6_addr)) vm.value = vm_value return vm.value @@ -128,7 +135,7 @@ class CreateImage(Resource): validator = schemas.CreateImageSchema(data) if validator.is_valid(): file_entry = shared.etcd_client.get( - join_path(settings['etcd']['file_prefix'], data["uuid"]) + join_path(settings["etcd"]["file_prefix"], data["uuid"]) ) file_entry_value = json.loads(file_entry.value) @@ -141,7 +148,9 @@ class CreateImage(Resource): "visibility": "public", } shared.etcd_client.put( - join_path(settings['etcd']['image_prefix'], data["uuid"]), + join_path( + settings["etcd"]["image_prefix"], data["uuid"] + ), json.dumps(image_entry_json), ) @@ -153,11 +162,9 @@ class ListPublicImages(Resource): @staticmethod def get(): images = shared.etcd_client.get_prefix( - settings['etcd']['image_prefix'], value_in_json=True + settings["etcd"]["image_prefix"], value_in_json=True ) - r = { - "images": [] - } + r = {"images": []} for image in images: image_key = "{}:{}".format( image.value["store_name"], image.value["name"] @@ -176,7 +183,7 @@ class VMAction(Resource): if validator.is_valid(): vm_entry = shared.vm_pool.get( - join_path(settings['etcd']['vm_prefix'], data["uuid"]) + join_path(settings["etcd"]["vm_prefix"], data["uuid"]) ) action = data["action"] @@ -184,13 +191,19 @@ class VMAction(Resource): action = "schedule" if action == "delete" and vm_entry.hostname == "": - if shared.storage_handler.is_vm_image_exists(vm_entry.uuid): - r_status = shared.storage_handler.delete_vm_image(vm_entry.uuid) + if shared.storage_handler.is_vm_image_exists( + vm_entry.uuid + ): + r_status = shared.storage_handler.delete_vm_image( + vm_entry.uuid + ) if r_status: shared.etcd_client.client.delete(vm_entry.key) return {"message": "VM successfully deleted"} else: - logger.error("Some Error Occurred while deleting VM") + logger.error( + "Some Error Occurred while deleting VM" + ) return {"message": "VM deletion unsuccessfull"} else: shared.etcd_client.client.delete(vm_entry.key) @@ -200,10 +213,13 @@ class VMAction(Resource): type="{}VM".format(action.title()), uuid=data["uuid"], hostname=vm_entry.hostname, - request_prefix=settings['etcd']['request_prefix'] + request_prefix=settings["etcd"]["request_prefix"], ) shared.request_pool.put(r) - return {"message": "VM {} Queued".format(action.title())}, 200 + return ( + {"message": "VM {} Queued".format(action.title())}, + 200, + ) else: return validator.get_errors(), 400 @@ -216,15 +232,21 @@ class VMMigration(Resource): if validator.is_valid(): vm = shared.vm_pool.get(data["uuid"]) - r = RequestEntry.from_scratch(type=RequestType.InitVMMigration, - uuid=vm.uuid, - hostname=join_path( - settings['etcd']['host_prefix'], validator.destination.value - ), - request_prefix=settings['etcd']['request_prefix']) + r = RequestEntry.from_scratch( + type=RequestType.InitVMMigration, + uuid=vm.uuid, + hostname=join_path( + settings["etcd"]["host_prefix"], + validator.destination.value, + ), + request_prefix=settings["etcd"]["request_prefix"], + ) shared.request_pool.put(r) - return {"message": "VM Migration Initialization Queued"}, 200 + return ( + {"message": "VM Migration Initialization Queued"}, + 200, + ) else: return validator.get_errors(), 400 @@ -237,10 +259,12 @@ class ListUserVM(Resource): if validator.is_valid(): vms = shared.etcd_client.get_prefix( - settings['etcd']['vm_prefix'], value_in_json=True + settings["etcd"]["vm_prefix"], value_in_json=True ) return_vms = [] - user_vms = filter(lambda v: v.value["owner"] == data["name"], vms) + user_vms = filter( + lambda v: v.value["owner"] == data["name"], vms + ) for vm in user_vms: return_vms.append( { @@ -249,9 +273,7 @@ class ListUserVM(Resource): "specs": vm.value["specs"], "status": vm.value["status"], "hostname": vm.value["hostname"], - "vnc_socket": None - if vm.value.get("vnc_socket", None) is None - else vm.value["vnc_socket"], + "vnc_socket": vm.value.get("vnc_socket", None), } ) if return_vms: @@ -270,11 +292,13 @@ class ListUserFiles(Resource): if validator.is_valid(): files = shared.etcd_client.get_prefix( - settings['etcd']['file_prefix'], value_in_json=True + settings["etcd"]["file_prefix"], value_in_json=True ) return_files = [] user_files = list( - filter(lambda f: f.value["owner"] == data["name"], files) + filter( + lambda f: f.value["owner"] == data["name"], files + ) ) for file in user_files: return_files.append( @@ -294,14 +318,18 @@ class CreateHost(Resource): data = request.json validator = schemas.CreateHostSchema(data) if validator.is_valid(): - host_key = join_path(settings['etcd']['host_prefix'], uuid4().hex) + host_key = join_path( + settings["etcd"]["host_prefix"], uuid4().hex + ) host_entry = { "specs": data["specs"], "hostname": data["hostname"], "status": "DEAD", "last_heartbeat": "", } - shared.etcd_client.put(host_key, host_entry, value_in_json=True) + shared.etcd_client.put( + host_key, host_entry, value_in_json=True + ) return {"message": "Host Created"}, 200 @@ -333,7 +361,7 @@ class GetSSHKeys(Resource): # {user_prefix}/{realm}/{name}/key/ etcd_key = join_path( - settings['etcd']['user_prefix'], + settings["etcd"]["user_prefix"], data["realm"], data["name"], "key", @@ -343,25 +371,30 @@ class GetSSHKeys(Resource): ) keys = { - key.key.split("/")[-1]: key.value for key in etcd_entry + key.key.split("/")[-1]: key.value + for key in etcd_entry } return {"keys": keys} else: # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - settings['etcd']['user_prefix'], + settings["etcd"]["user_prefix"], data["realm"], data["name"], "key", data["key_name"], ) - etcd_entry = shared.etcd_client.get(etcd_key, value_in_json=True) + etcd_entry = shared.etcd_client.get( + etcd_key, value_in_json=True + ) if etcd_entry: return { "keys": { - etcd_entry.key.split("/")[-1]: etcd_entry.value + etcd_entry.key.split("/")[ + -1 + ]: etcd_entry.value } } else: @@ -379,13 +412,15 @@ class AddSSHKey(Resource): # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - settings['etcd']['user_prefix'], + settings["etcd"]["user_prefix"], data["realm"], data["name"], "key", data["key_name"], ) - etcd_entry = shared.etcd_client.get(etcd_key, value_in_json=True) + etcd_entry = shared.etcd_client.get( + etcd_key, value_in_json=True + ) if etcd_entry: return { "message": "Key with name '{}' already exists".format( @@ -394,7 +429,9 @@ class AddSSHKey(Resource): } else: # Key Not Found. It implies user' haven't added any key yet. - shared.etcd_client.put(etcd_key, data["key"], value_in_json=True) + shared.etcd_client.put( + etcd_key, data["key"], value_in_json=True + ) return {"message": "Key added successfully"} else: return validator.get_errors(), 400 @@ -409,13 +446,15 @@ class RemoveSSHKey(Resource): # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - settings['etcd']['user_prefix'], + settings["etcd"]["user_prefix"], data["realm"], data["name"], "key", data["key_name"], ) - etcd_entry = shared.etcd_client.get(etcd_key, value_in_json=True) + etcd_entry = shared.etcd_client.get( + etcd_key, value_in_json=True + ) if etcd_entry: shared.etcd_client.client.delete(etcd_key) return {"message": "Key successfully removed."} @@ -446,15 +485,17 @@ class CreateNetwork(Resource): if validator.user.value: try: nb = pynetbox.api( - url=settings['netbox']['url'], - token=settings['netbox']['token'], + url=settings["netbox"]["url"], + token=settings["netbox"]["token"], ) nb_prefix = nb.ipam.prefixes.get( - prefix=settings['network']['prefix'] + prefix=settings["network"]["prefix"] ) prefix = nb_prefix.available_prefixes.create( data={ - "prefix_length": int(settings['network']['prefix_length']), + "prefix_length": int( + settings["network"]["prefix_length"] + ), "description": '{}\'s network "{}"'.format( data["name"], data["network_name"] ), @@ -463,18 +504,22 @@ class CreateNetwork(Resource): ) except Exception as err: app.logger.error(err) - return {"message": "Error occured while creating network."} + return { + "message": "Error occured while creating network." + } else: network_entry["ipv6"] = prefix["prefix"] else: network_entry["ipv6"] = "fd00::/64" network_key = join_path( - settings['etcd']['network_prefix'], - data['name'], - data['network_name'], + settings["etcd"]["network_prefix"], + data["name"], + data["network_name"], + ) + shared.etcd_client.put( + network_key, network_entry, value_in_json=True ) - shared.etcd_client.put(network_key, network_entry, value_in_json=True) return {"message": "Network successfully added."} else: return validator.get_errors(), 400 @@ -488,9 +533,11 @@ class ListUserNetwork(Resource): if validator.is_valid(): prefix = join_path( - settings['etcd']['network_prefix'], data["name"] + settings["etcd"]["network_prefix"], data["name"] + ) + networks = shared.etcd_client.get_prefix( + prefix, value_in_json=True ) - networks = shared.etcd_client.get_prefix(prefix, value_in_json=True) user_networks = [] for net in networks: net.value["name"] = net.key.split("/")[-1] @@ -524,7 +571,11 @@ api.add_resource(CreateNetwork, "/network/create") def main(): - image_stores = list(shared.etcd_client.get_prefix(settings['etcd']['image_store_prefix'], value_in_json=True)) + image_stores = list( + shared.etcd_client.get_prefix( + settings["etcd"]["image_store_prefix"], value_in_json=True + ) + ) if len(image_stores) == 0: data = { "is_public": True, @@ -534,7 +585,12 @@ def main(): "attributes": {"list": [], "key": [], "pool": "images"}, } - shared.etcd_client.put(join_path(settings['etcd']['image_store_prefix'], uuid4().hex), json.dumps(data)) + shared.etcd_client.put( + join_path( + settings["etcd"]["image_store_prefix"], uuid4().hex + ), + json.dumps(data), + ) app.run(host="::", debug=True) diff --git a/ucloud/api/schemas.py b/ucloud/api/schemas.py index d639be4..a848a7d 100755 --- a/ucloud/api/schemas.py +++ b/ucloud/api/schemas.py @@ -80,7 +80,12 @@ class OTPSchema(BaseSchema): super().__init__(data=data, fields=_fields) def validation(self): - if check_otp(self.name.value, self.realm.value, self.token.value) != 200: + if ( + check_otp( + self.name.value, self.realm.value, self.token.value + ) + != 200 + ): self.add_error("Wrong Credentials") @@ -92,7 +97,9 @@ class CreateImageSchema(BaseSchema): # Fields self.uuid = Field("uuid", str, data.get("uuid", KeyError)) self.name = Field("name", str, data.get("name", KeyError)) - self.image_store = Field("image_store", str, data.get("image_store", KeyError)) + self.image_store = Field( + "image_store", str, data.get("image_store", KeyError) + ) # Validations self.uuid.validation = self.file_uuid_validation @@ -103,34 +110,52 @@ class CreateImageSchema(BaseSchema): super().__init__(data, fields) def file_uuid_validation(self): - file_entry = shared.etcd_client.get(os.path.join(settings['etcd']['file_prefix'], self.uuid.value)) + file_entry = shared.etcd_client.get( + os.path.join( + settings["etcd"]["file_prefix"], self.uuid.value + ) + ) if file_entry is None: self.add_error( - "Image File with uuid '{}' Not Found".format(self.uuid.value) + "Image File with uuid '{}' Not Found".format( + self.uuid.value + ) ) def image_store_name_validation(self): - image_stores = list(shared.etcd_client.get_prefix(settings['etcd']['image_store_prefix'])) + image_stores = list( + shared.etcd_client.get_prefix( + settings["etcd"]["image_store_prefix"] + ) + ) image_store = next( filter( - lambda s: json.loads(s.value)["name"] == self.image_store.value, + lambda s: json.loads(s.value)["name"] + == self.image_store.value, image_stores, ), None, ) if not image_store: - self.add_error("Store '{}' does not exists".format(self.image_store.value)) + self.add_error( + "Store '{}' does not exists".format( + self.image_store.value + ) + ) # Host Operations + class CreateHostSchema(OTPSchema): def __init__(self, data): self.parsed_specs = {} # Fields self.specs = Field("specs", dict, data.get("specs", KeyError)) - self.hostname = Field("hostname", str, data.get("hostname", KeyError)) + self.hostname = Field( + "hostname", str, data.get("hostname", KeyError) + ) # Validation self.specs.validation = self.specs_validation @@ -142,22 +167,28 @@ class CreateHostSchema(OTPSchema): def specs_validation(self): ALLOWED_BASE = 10 - _cpu = self.specs.value.get('cpu', KeyError) - _ram = self.specs.value.get('ram', KeyError) - _os_ssd = self.specs.value.get('os-ssd', KeyError) - _hdd = self.specs.value.get('hdd', KeyError) + _cpu = self.specs.value.get("cpu", KeyError) + _ram = self.specs.value.get("ram", KeyError) + _os_ssd = self.specs.value.get("os-ssd", KeyError) + _hdd = self.specs.value.get("hdd", KeyError) if KeyError in [_cpu, _ram, _os_ssd, _hdd]: - self.add_error("You must specify CPU, RAM and OS-SSD in your specs") + self.add_error( + "You must specify CPU, RAM and OS-SSD in your specs" + ) return None try: parsed_ram = bitmath.parse_string_unsafe(_ram) parsed_os_ssd = bitmath.parse_string_unsafe(_os_ssd) if parsed_ram.base != ALLOWED_BASE: - self.add_error("Your specified RAM is not in correct units") + self.add_error( + "Your specified RAM is not in correct units" + ) if parsed_os_ssd.base != ALLOWED_BASE: - self.add_error("Your specified OS-SSD is not in correct units") + self.add_error( + "Your specified OS-SSD is not in correct units" + ) if _cpu < 1: self.add_error("CPU must be atleast 1") @@ -172,7 +203,9 @@ class CreateHostSchema(OTPSchema): for hdd in _hdd: _parsed_hdd = bitmath.parse_string_unsafe(hdd) if _parsed_hdd.base != ALLOWED_BASE: - self.add_error("Your specified HDD is not in correct units") + self.add_error( + "Your specified HDD is not in correct units" + ) break else: parsed_hdd.append(str(_parsed_hdd)) @@ -183,15 +216,17 @@ class CreateHostSchema(OTPSchema): else: if self.get_errors(): self.specs = { - 'cpu': _cpu, - 'ram': str(parsed_ram), - 'os-ssd': str(parsed_os_ssd), - 'hdd': parsed_hdd + "cpu": _cpu, + "ram": str(parsed_ram), + "os-ssd": str(parsed_os_ssd), + "hdd": parsed_hdd, } def validation(self): if self.realm.value != "ungleich-admin": - self.add_error("Invalid Credentials/Insufficient Permission") + self.add_error( + "Invalid Credentials/Insufficient Permission" + ) # VM Operations @@ -203,9 +238,13 @@ class CreateVMSchema(OTPSchema): # Fields self.specs = Field("specs", dict, data.get("specs", KeyError)) - self.vm_name = Field("vm_name", str, data.get("vm_name", KeyError)) + self.vm_name = Field( + "vm_name", str, data.get("vm_name", KeyError) + ) self.image = Field("image", str, data.get("image", KeyError)) - self.network = Field("network", list, data.get("network", KeyError)) + self.network = Field( + "network", list, data.get("network", KeyError) + ) # Validation self.image.validation = self.image_validation @@ -219,17 +258,25 @@ class CreateVMSchema(OTPSchema): def image_validation(self): try: - image_uuid = helper.resolve_image_name(self.image.value, shared.etcd_client) + image_uuid = helper.resolve_image_name( + self.image.value, shared.etcd_client + ) except Exception as e: - logger.exception("Cannot resolve image name = %s", self.image.value) + logger.exception( + "Cannot resolve image name = %s", self.image.value + ) self.add_error(str(e)) else: self.image_uuid = image_uuid def vm_name_validation(self): - if resolve_vm_name(name=self.vm_name.value, owner=self.name.value): + if resolve_vm_name( + name=self.vm_name.value, owner=self.name.value + ): self.add_error( - 'VM with same name "{}" already exists'.format(self.vm_name.value) + 'VM with same name "{}" already exists'.format( + self.vm_name.value + ) ) def network_validation(self): @@ -237,32 +284,46 @@ class CreateVMSchema(OTPSchema): if _network: for net in _network: - network = shared.etcd_client.get(os.path.join(settings['etcd']['network_prefix'], - self.name.value, - net), value_in_json=True) + network = shared.etcd_client.get( + os.path.join( + settings["etcd"]["network_prefix"], + self.name.value, + net, + ), + value_in_json=True, + ) if not network: - self.add_error("Network with name {} does not exists" \ - .format(net)) + self.add_error( + "Network with name {} does not exists".format( + net + ) + ) def specs_validation(self): ALLOWED_BASE = 10 - _cpu = self.specs.value.get('cpu', KeyError) - _ram = self.specs.value.get('ram', KeyError) - _os_ssd = self.specs.value.get('os-ssd', KeyError) - _hdd = self.specs.value.get('hdd', KeyError) + _cpu = self.specs.value.get("cpu", KeyError) + _ram = self.specs.value.get("ram", KeyError) + _os_ssd = self.specs.value.get("os-ssd", KeyError) + _hdd = self.specs.value.get("hdd", KeyError) if KeyError in [_cpu, _ram, _os_ssd, _hdd]: - self.add_error("You must specify CPU, RAM and OS-SSD in your specs") + self.add_error( + "You must specify CPU, RAM and OS-SSD in your specs" + ) return None try: parsed_ram = bitmath.parse_string_unsafe(_ram) parsed_os_ssd = bitmath.parse_string_unsafe(_os_ssd) if parsed_ram.base != ALLOWED_BASE: - self.add_error("Your specified RAM is not in correct units") + self.add_error( + "Your specified RAM is not in correct units" + ) if parsed_os_ssd.base != ALLOWED_BASE: - self.add_error("Your specified OS-SSD is not in correct units") + self.add_error( + "Your specified OS-SSD is not in correct units" + ) if _cpu < 1: self.add_error("CPU must be atleast 1") @@ -277,7 +338,9 @@ class CreateVMSchema(OTPSchema): for hdd in _hdd: _parsed_hdd = bitmath.parse_string_unsafe(hdd) if _parsed_hdd.base != ALLOWED_BASE: - self.add_error("Your specified HDD is not in correct units") + self.add_error( + "Your specified HDD is not in correct units" + ) break else: parsed_hdd.append(str(_parsed_hdd)) @@ -288,21 +351,24 @@ class CreateVMSchema(OTPSchema): else: if self.get_errors(): self.specs = { - 'cpu': _cpu, - 'ram': str(parsed_ram), - 'os-ssd': str(parsed_os_ssd), - 'hdd': parsed_hdd + "cpu": _cpu, + "ram": str(parsed_ram), + "os-ssd": str(parsed_os_ssd), + "hdd": parsed_hdd, } class VMStatusSchema(OTPSchema): def __init__(self, data): data["uuid"] = ( - resolve_vm_name( - name=data.get("vm_name", None), - owner=(data.get("in_support_of", None) or data.get("name", None)), - ) - or KeyError + resolve_vm_name( + name=data.get("vm_name", None), + owner=( + data.get("in_support_of", None) + or data.get("name", None) + ), + ) + or KeyError ) self.uuid = VmUUIDField(data) @@ -313,7 +379,8 @@ class VMStatusSchema(OTPSchema): def validation(self): vm = shared.vm_pool.get(self.uuid.value) if not ( - vm.value["owner"] == self.name.value or self.realm.value == "ungleich-admin" + vm.value["owner"] == self.name.value + or self.realm.value == "ungleich-admin" ): self.add_error("Invalid User") @@ -321,11 +388,14 @@ class VMStatusSchema(OTPSchema): class VmActionSchema(OTPSchema): def __init__(self, data): data["uuid"] = ( - resolve_vm_name( - name=data.get("vm_name", None), - owner=(data.get("in_support_of", None) or data.get("name", None)), - ) - or KeyError + resolve_vm_name( + name=data.get("vm_name", None), + owner=( + data.get("in_support_of", None) + or data.get("name", None) + ), + ) + or KeyError ) self.uuid = VmUUIDField(data) self.action = Field("action", str, data.get("action", KeyError)) @@ -340,20 +410,23 @@ class VmActionSchema(OTPSchema): allowed_actions = ["start", "stop", "delete"] if self.action.value not in allowed_actions: self.add_error( - "Invalid Action. Allowed Actions are {}".format(allowed_actions) + "Invalid Action. Allowed Actions are {}".format( + allowed_actions + ) ) def validation(self): vm = shared.vm_pool.get(self.uuid.value) if not ( - vm.value["owner"] == self.name.value or self.realm.value == "ungleich-admin" + vm.value["owner"] == self.name.value + or self.realm.value == "ungleich-admin" ): self.add_error("Invalid User") if ( - self.action.value == "start" - and vm.status == VMStatus.running - and vm.hostname != "" + self.action.value == "start" + and vm.status == VMStatus.running + and vm.hostname != "" ): self.add_error("VM Already Running") @@ -367,15 +440,20 @@ class VmActionSchema(OTPSchema): class VmMigrationSchema(OTPSchema): def __init__(self, data): data["uuid"] = ( - resolve_vm_name( - name=data.get("vm_name", None), - owner=(data.get("in_support_of", None) or data.get("name", None)), - ) - or KeyError + resolve_vm_name( + name=data.get("vm_name", None), + owner=( + data.get("in_support_of", None) + or data.get("name", None) + ), + ) + or KeyError ) self.uuid = VmUUIDField(data) - self.destination = Field("destination", str, data.get("destination", KeyError)) + self.destination = Field( + "destination", str, data.get("destination", KeyError) + ) self.destination.validation = self.destination_validation @@ -384,9 +462,18 @@ class VmMigrationSchema(OTPSchema): def destination_validation(self): hostname = self.destination.value - host = next(filter(lambda h: h.hostname == hostname, shared.host_pool.hosts), None) + host = next( + filter( + lambda h: h.hostname == hostname, shared.host_pool.hosts + ), + None, + ) if not host: - self.add_error("No Such Host ({}) exists".format(self.destination.value)) + self.add_error( + "No Such Host ({}) exists".format( + self.destination.value + ) + ) elif host.status != HostStatus.alive: self.add_error("Destination Host is dead") else: @@ -395,20 +482,27 @@ class VmMigrationSchema(OTPSchema): def validation(self): vm = shared.vm_pool.get(self.uuid.value) if not ( - vm.value["owner"] == self.name.value or self.realm.value == "ungleich-admin" + vm.value["owner"] == self.name.value + or self.realm.value == "ungleich-admin" ): self.add_error("Invalid User") if vm.status != VMStatus.running: self.add_error("Can't migrate non-running VM") - if vm.hostname == os.path.join(settings['etcd']['host_prefix'], self.destination.value): - self.add_error("Destination host couldn't be same as Source Host") + if vm.hostname == os.path.join( + settings["etcd"]["host_prefix"], self.destination.value + ): + self.add_error( + "Destination host couldn't be same as Source Host" + ) class AddSSHSchema(OTPSchema): def __init__(self, data): - self.key_name = Field("key_name", str, data.get("key_name", KeyError)) + self.key_name = Field( + "key_name", str, data.get("key_name", KeyError) + ) self.key = Field("key", str, data.get("key_name", KeyError)) fields = [self.key_name, self.key] @@ -417,7 +511,9 @@ class AddSSHSchema(OTPSchema): class RemoveSSHSchema(OTPSchema): def __init__(self, data): - self.key_name = Field("key_name", str, data.get("key_name", KeyError)) + self.key_name = Field( + "key_name", str, data.get("key_name", KeyError) + ) fields = [self.key_name] super().__init__(data=data, fields=fields) @@ -425,7 +521,9 @@ class RemoveSSHSchema(OTPSchema): class GetSSHSchema(OTPSchema): def __init__(self, data): - self.key_name = Field("key_name", str, data.get("key_name", None)) + self.key_name = Field( + "key_name", str, data.get("key_name", None) + ) fields = [self.key_name] super().__init__(data=data, fields=fields) @@ -433,7 +531,9 @@ class GetSSHSchema(OTPSchema): class CreateNetwork(OTPSchema): def __init__(self, data): - self.network_name = Field("network_name", str, data.get("network_name", KeyError)) + self.network_name = Field( + "network_name", str, data.get("network_name", KeyError) + ) self.type = Field("type", str, data.get("type", KeyError)) self.user = Field("user", bool, bool(data.get("user", False))) @@ -444,15 +544,26 @@ class CreateNetwork(OTPSchema): super().__init__(data, fields=fields) def network_name_validation(self): - network = shared.etcd_client.get(os.path.join(settings['etcd']['network_prefix'], - self.name.value, - self.network_name.value), - value_in_json=True) + network = shared.etcd_client.get( + os.path.join( + settings["etcd"]["network_prefix"], + self.name.value, + self.network_name.value, + ), + value_in_json=True, + ) if network: - self.add_error("Network with name {} already exists" \ - .format(self.network_name.value)) + self.add_error( + "Network with name {} already exists".format( + self.network_name.value + ) + ) def network_type_validation(self): supported_network_types = ["vxlan"] if self.type.value not in supported_network_types: - self.add_error("Unsupported Network Type. Supported network types are {}".format(supported_network_types)) + self.add_error( + "Unsupported Network Type. Supported network types are {}".format( + supported_network_types + ) + ) diff --git a/ucloud/common/etcd_wrapper.py b/ucloud/common/etcd_wrapper.py index 5f464e1..7367a6c 100644 --- a/ucloud/common/etcd_wrapper.py +++ b/ucloud/common/etcd_wrapper.py @@ -8,7 +8,7 @@ from functools import wraps from . import logger -PseudoEtcdMeta = namedtuple('PseudoEtcdMeta', ['key']) +PseudoEtcdMeta = namedtuple("PseudoEtcdMeta", ["key"]) class EtcdEntry: @@ -16,8 +16,8 @@ class EtcdEntry: # value: str def __init__(self, meta, value, value_in_json=False): - self.key = meta.key.decode('utf-8') - self.value = value.decode('utf-8') + self.key = meta.key.decode("utf-8") + self.value = value.decode("utf-8") if value_in_json: self.value = json.loads(self.value) @@ -29,11 +29,18 @@ def readable_errors(func): try: return func(*args, **kwargs) except etcd3.exceptions.ConnectionFailedError as err: - raise etcd3.exceptions.ConnectionFailedError('etcd connection failed.') from err + raise etcd3.exceptions.ConnectionFailedError( + "etcd connection failed." + ) from err except etcd3.exceptions.ConnectionTimeoutError as err: - raise etcd3.exceptions.ConnectionTimeoutError('etcd connection timeout.') from err + raise etcd3.exceptions.ConnectionTimeoutError( + "etcd connection timeout." + ) from err except Exception: - logger.exception('Some etcd error occured. See syslog for details.') + logger.exception( + "Some etcd error occured. See syslog for details." + ) + return wrapper @@ -56,7 +63,7 @@ class Etcd3Wrapper: _value = json.dumps(_value) if not isinstance(_key, str): - _key = _key.decode('utf-8') + _key = _key.decode("utf-8") return self.client.put(_key, _value, **kwargs) @@ -70,18 +77,25 @@ class Etcd3Wrapper: @readable_errors def watch_prefix(self, key, timeout=0, value_in_json=False): - timeout_event = EtcdEntry(PseudoEtcdMeta(key=b'TIMEOUT'), - value=str.encode(json.dumps({'status': 'TIMEOUT', - 'type': 'TIMEOUT'})), - value_in_json=value_in_json) + timeout_event = EtcdEntry( + PseudoEtcdMeta(key=b"TIMEOUT"), + value=str.encode( + json.dumps({"status": "TIMEOUT", "type": "TIMEOUT"}) + ), + value_in_json=value_in_json, + ) event_queue = queue.Queue() def add_event_to_queue(event): - if hasattr(event, 'events'): + if hasattr(event, "events"): for e in event.events: if e.value: - event_queue.put(EtcdEntry(e, e.value, value_in_json=value_in_json)) + event_queue.put( + EtcdEntry( + e, e.value, value_in_json=value_in_json + ) + ) self.client.add_watch_prefix_callback(key, add_event_to_queue) @@ -96,4 +110,8 @@ class Etcd3Wrapper: class PsuedoEtcdEntry(EtcdEntry): def __init__(self, key, value, value_in_json=False): - super().__init__(PseudoEtcdMeta(key=key.encode('utf-8')), value, value_in_json=value_in_json) + super().__init__( + PseudoEtcdMeta(key=key.encode("utf-8")), + value, + value_in_json=value_in_json, + ) diff --git a/ucloud/common/host.py b/ucloud/common/host.py index ccbf7a8..191a2c0 100644 --- a/ucloud/common/host.py +++ b/ucloud/common/host.py @@ -29,7 +29,9 @@ class HostEntry(SpecificEtcdEntryBase): self.last_heartbeat = time.strftime("%Y-%m-%d %H:%M:%S") def is_alive(self): - last_heartbeat = datetime.strptime(self.last_heartbeat, "%Y-%m-%d %H:%M:%S") + last_heartbeat = datetime.strptime( + self.last_heartbeat, "%Y-%m-%d %H:%M:%S" + ) delta = datetime.now() - last_heartbeat if delta.total_seconds() > 60: return False diff --git a/ucloud/common/logging.py b/ucloud/common/logging.py index ba1e59d..9e0d2be 100644 --- a/ucloud/common/logging.py +++ b/ucloud/common/logging.py @@ -7,21 +7,21 @@ class NoTracebackStreamHandler(logging.StreamHandler): info, cache = record.exc_info, record.exc_text record.exc_info, record.exc_text = None, None - if record.levelname in ['WARNING', 'WARN']: + if record.levelname in ["WARNING", "WARN"]: color = colorama.Fore.LIGHTYELLOW_EX - elif record.levelname == 'ERROR': + elif record.levelname == "ERROR": color = colorama.Fore.LIGHTRED_EX - elif record.levelname == 'INFO': + elif record.levelname == "INFO": color = colorama.Fore.LIGHTGREEN_EX - elif record.levelname == 'CRITICAL': + elif record.levelname == "CRITICAL": color = colorama.Fore.LIGHTCYAN_EX else: color = colorama.Fore.WHITE try: - print(color, end='', flush=True) + print(color, end="", flush=True) super().handle(record) finally: record.exc_info = info record.exc_text = cache - print(colorama.Style.RESET_ALL, end='', flush=True) + print(colorama.Style.RESET_ALL, end="", flush=True) diff --git a/ucloud/common/network.py b/ucloud/common/network.py index 1503446..61dbd64 100644 --- a/ucloud/common/network.py +++ b/ucloud/common/network.py @@ -11,7 +11,9 @@ def random_bytes(num=6): return [random.randrange(256) for _ in range(num)] -def generate_mac(uaa=False, multicast=False, oui=None, separator=':', byte_fmt='%02x'): +def generate_mac( + uaa=False, multicast=False, oui=None, separator=":", byte_fmt="%02x" +): mac = random_bytes() if oui: if type(oui) == str: @@ -30,35 +32,51 @@ def generate_mac(uaa=False, multicast=False, oui=None, separator=':', byte_fmt=' def create_dev(script, _id, dev, ip=None): - command = ['sudo', '-p', 'Enter password to create network devices for vm: ', - script, str(_id), dev] + command = [ + "sudo", + "-p", + "Enter password to create network devices for vm: ", + script, + str(_id), + dev, + ] if ip: command.append(ip) try: output = sp.check_output(command, stderr=sp.PIPE) except Exception: - logger.exception('Creation of interface %s failed.', dev) + logger.exception("Creation of interface %s failed.", dev) return None else: - return output.decode('utf-8').strip() + return output.decode("utf-8").strip() def delete_network_interface(iface): try: sp.check_output( [ - 'sudo', '-p', 'Enter password to remove {} network device: '.format(iface), - 'ip', 'link', 'del', iface - ], stderr=sp.PIPE + "sudo", + "-p", + "Enter password to remove {} network device: ".format( + iface + ), + "ip", + "link", + "del", + iface, + ], + stderr=sp.PIPE, ) except Exception: - logger.exception('Interface %s Deletion failed', iface) + logger.exception("Interface %s Deletion failed", iface) def find_free_port(): - with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: + with closing( + socket.socket(socket.AF_INET, socket.SOCK_STREAM) + ) as s: try: - s.bind(('', 0)) + s.bind(("", 0)) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) except Exception: return None diff --git a/ucloud/common/request.py b/ucloud/common/request.py index 5705eed..a8c2d0a 100644 --- a/ucloud/common/request.py +++ b/ucloud/common/request.py @@ -17,7 +17,6 @@ class RequestType: class RequestEntry(SpecificEtcdEntryBase): - def __init__(self, e): self.destination_sock_path = None self.destination_host_key = None @@ -30,8 +29,11 @@ class RequestEntry(SpecificEtcdEntryBase): @classmethod def from_scratch(cls, request_prefix, **kwargs): - e = PsuedoEtcdEntry(join(request_prefix, uuid4().hex), - value=json.dumps(kwargs).encode("utf-8"), value_in_json=True) + e = PsuedoEtcdEntry( + join(request_prefix, uuid4().hex), + value=json.dumps(kwargs).encode("utf-8"), + value_in_json=True, + ) return cls(e) diff --git a/ucloud/common/schemas.py b/ucloud/common/schemas.py index a592ec2..04978a5 100644 --- a/ucloud/common/schemas.py +++ b/ucloud/common/schemas.py @@ -14,7 +14,7 @@ class StorageUnit(fields.Field): class SpecsSchema(Schema): cpu = fields.Int() ram = StorageUnit() - os_ssd = StorageUnit(data_key='os-ssd', attribute='os-ssd') + os_ssd = StorageUnit(data_key="os-ssd", attribute="os-ssd") hdd = fields.List(StorageUnit()) @@ -29,11 +29,13 @@ class VMSchema(Schema): image_uuid = fields.Str() hostname = fields.Str() metadata = fields.Dict() - network = fields.List(fields.Tuple((fields.Str(), fields.Str(), fields.Int()))) + network = fields.List( + fields.Tuple((fields.Str(), fields.Str(), fields.Int())) + ) in_migration = fields.Bool() class NetworkSchema(Schema): - _id = fields.Int(data_key='id', attribute='id') - _type = fields.Str(data_key='type', attribute='type') + _id = fields.Int(data_key="id", attribute="id") + _type = fields.Str(data_key="type", attribute="type") ipv6 = fields.Str() diff --git a/ucloud/common/storage_handlers.py b/ucloud/common/storage_handlers.py index d2190ba..b337f23 100644 --- a/ucloud/common/storage_handlers.py +++ b/ucloud/common/storage_handlers.py @@ -11,7 +11,7 @@ from ucloud.settings import settings as config class ImageStorageHandler(ABC): - handler_name = 'base' + handler_name = "base" def __init__(self, image_base, vm_base): self.image_base = image_base @@ -55,9 +55,9 @@ class ImageStorageHandler(ABC): try: sp.check_output(command, stderr=sp.PIPE) except sp.CalledProcessError as e: - _stderr = e.stderr.decode('utf-8').strip() + _stderr = e.stderr.decode("utf-8").strip() if report: - logger.exception('%s:- %s', error_origin, _stderr) + logger.exception("%s:- %s", error_origin, _stderr) return False return True @@ -72,14 +72,16 @@ class ImageStorageHandler(ABC): class FileSystemBasedImageStorageHandler(ImageStorageHandler): - handler_name = 'Filesystem' + handler_name = "Filesystem" def import_image(self, src, dest, protect=False): dest = join_path(self.image_base, dest) try: shutil.copy(src, dest) if protect: - os.chmod(dest, stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH) + os.chmod( + dest, stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH + ) except Exception as e: logger.exception(e) return False @@ -97,7 +99,14 @@ class FileSystemBasedImageStorageHandler(ImageStorageHandler): def resize_vm_image(self, path, size): path = join_path(self.vm_base, path) - command = ["qemu-img", "resize", "-f", "raw", path, "{}M".format(size)] + command = [ + "qemu-img", + "resize", + "-f", + "raw", + path, + "{}M".format(size), + ] if self.execute_command(command): return True else: @@ -126,15 +135,25 @@ class FileSystemBasedImageStorageHandler(ImageStorageHandler): class CEPHBasedImageStorageHandler(ImageStorageHandler): - handler_name = 'Ceph' + handler_name = "Ceph" def import_image(self, src, dest, protect=False): dest = join_path(self.image_base, dest) import_command = ["rbd", "import", src, dest] commands = [import_command] if protect: - snap_create_command = ["rbd", "snap", "create", "{}@protected".format(dest)] - snap_protect_command = ["rbd", "snap", "protect", "{}@protected".format(dest)] + snap_create_command = [ + "rbd", + "snap", + "create", + "{}@protected".format(dest), + ] + snap_protect_command = [ + "rbd", + "snap", + "protect", + "{}@protected".format(dest), + ] commands.append(snap_create_command) commands.append(snap_protect_command) @@ -174,16 +193,16 @@ class CEPHBasedImageStorageHandler(ImageStorageHandler): def get_storage_handler(): - __storage_backend = config['storage']['storage_backend'] - if __storage_backend == 'filesystem': + __storage_backend = config["storage"]["storage_backend"] + if __storage_backend == "filesystem": return FileSystemBasedImageStorageHandler( - vm_base=config['storage']['vm_dir'], - image_base=config['storage']['image_dir'] + vm_base=config["storage"]["vm_dir"], + image_base=config["storage"]["image_dir"], ) - elif __storage_backend == 'ceph': + elif __storage_backend == "ceph": return CEPHBasedImageStorageHandler( - vm_base=config['storage']['ceph_vm_pool'], - image_base=config['storage']['ceph_image_pool'] + vm_base=config["storage"]["ceph_vm_pool"], + image_base=config["storage"]["ceph_image_pool"], ) else: - raise Exception('Unknown Image Storage Handler') + raise Exception("Unknown Image Storage Handler") diff --git a/ucloud/common/vm.py b/ucloud/common/vm.py index 238f19d..d11046d 100644 --- a/ucloud/common/vm.py +++ b/ucloud/common/vm.py @@ -13,13 +13,12 @@ class VMStatus: def declare_stopped(vm): - vm['hostname'] = '' - vm['in_migration'] = False - vm['status'] = VMStatus.stopped + vm["hostname"] = "" + vm["in_migration"] = False + vm["status"] = VMStatus.stopped class VMEntry(SpecificEtcdEntryBase): - def __init__(self, e): self.owner = None # type: str self.specs = None # type: dict @@ -48,7 +47,9 @@ class VMEntry(SpecificEtcdEntryBase): def add_log(self, msg): self.log = self.log[:5] - self.log.append("{} - {}".format(datetime.now().isoformat(), msg)) + self.log.append( + "{} - {}".format(datetime.now().isoformat(), msg) + ) class VmPool: diff --git a/ucloud/configure/main.py b/ucloud/configure/main.py index e4770d9..31201f6 100644 --- a/ucloud/configure/main.py +++ b/ucloud/configure/main.py @@ -5,31 +5,41 @@ from ucloud.shared import shared def update_config(section, kwargs): - uncloud_config = shared.etcd_client.get(settings.config_key, value_in_json=True) + uncloud_config = shared.etcd_client.get( + settings.config_key, value_in_json=True + ) if not uncloud_config: uncloud_config = {} else: uncloud_config = uncloud_config.value - + uncloud_config[section] = kwargs - shared.etcd_client.put(settings.config_key, uncloud_config, value_in_json=True) + shared.etcd_client.put( + settings.config_key, uncloud_config, value_in_json=True + ) def configure_parser(parser): configure_subparsers = parser.add_subparsers(dest="subcommand") - + otp_parser = configure_subparsers.add_parser("otp") - otp_parser.add_argument("--verification-controller-url", - required=True, metavar="URL") - otp_parser.add_argument("--auth-name", required=True, - metavar="OTP-NAME") - otp_parser.add_argument("--auth-realm", required=True, - metavar="OTP-REALM") - otp_parser.add_argument("--auth-seed", required=True, - metavar="OTP-SEED") + otp_parser.add_argument( + "--verification-controller-url", required=True, metavar="URL" + ) + otp_parser.add_argument( + "--auth-name", required=True, metavar="OTP-NAME" + ) + otp_parser.add_argument( + "--auth-realm", required=True, metavar="OTP-REALM" + ) + otp_parser.add_argument( + "--auth-seed", required=True, metavar="OTP-SEED" + ) network_parser = configure_subparsers.add_parser("network") - network_parser.add_argument("--prefix-length", required=True, type=int) + network_parser.add_argument( + "--prefix-length", required=True, type=int + ) network_parser.add_argument("--prefix", required=True) network_parser.add_argument("--vxlan-phy-dev", required=True) @@ -38,25 +48,31 @@ def configure_parser(parser): netbox_parser.add_argument("--token", required=True) ssh_parser = configure_subparsers.add_parser("ssh") - ssh_parser.add_argument('--username', default="root") - ssh_parser.add_argument('--private-key-path', - default=os.path.expanduser("~/.ssh/id_rsa")) + ssh_parser.add_argument("--username", default="root") + ssh_parser.add_argument( + "--private-key-path", + default=os.path.expanduser("~/.ssh/id_rsa"), + ) storage_parser = configure_subparsers.add_parser("storage") - storage_parser.add_argument('--file-dir', required=True) - storage_parser_subparsers = storage_parser.add_subparsers(dest="storage_backend") - - filesystem_storage_parser = storage_parser_subparsers.add_parser("filesystem") - filesystem_storage_parser.add_argument('--vm-dir', required=True) - filesystem_storage_parser.add_argument('--image-dir', required=True) + storage_parser.add_argument("--file-dir", required=True) + storage_parser_subparsers = storage_parser.add_subparsers( + dest="storage_backend" + ) + + filesystem_storage_parser = storage_parser_subparsers.add_parser( + "filesystem" + ) + filesystem_storage_parser.add_argument("--vm-dir", required=True) + filesystem_storage_parser.add_argument("--image-dir", required=True) ceph_storage_parser = storage_parser_subparsers.add_parser("ceph") - ceph_storage_parser.add_argument('--ceph-vm-pool', required=True) - ceph_storage_parser.add_argument('--ceph-image-pool', required=True) + ceph_storage_parser.add_argument("--ceph-vm-pool", required=True) + ceph_storage_parser.add_argument("--ceph-image-pool", required=True) def main(**kwargs): - subcommand = kwargs.pop('subcommand') + subcommand = kwargs.pop("subcommand") if not subcommand: pass else: diff --git a/ucloud/docs/source/conf.py b/ucloud/docs/source/conf.py index 9b133f9..70307f8 100644 --- a/ucloud/docs/source/conf.py +++ b/ucloud/docs/source/conf.py @@ -17,9 +17,9 @@ # -- Project information ----------------------------------------------------- -project = 'ucloud' -copyright = '2019, ungleich' -author = 'ungleich' +project = "ucloud" +copyright = "2019, ungleich" +author = "ungleich" # -- General configuration --------------------------------------------------- @@ -27,12 +27,12 @@ author = 'ungleich' # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx_rtd_theme', + "sphinx.ext.autodoc", + "sphinx_rtd_theme", ] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -50,4 +50,4 @@ html_theme = "sphinx_rtd_theme" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] diff --git a/ucloud/filescanner/main.py b/ucloud/filescanner/main.py index 778e942..b12797b 100755 --- a/ucloud/filescanner/main.py +++ b/ucloud/filescanner/main.py @@ -21,7 +21,8 @@ def sha512sum(file: str): ELSE: return None """ - if not isinstance(file, str): raise TypeError + if not isinstance(file, str): + raise TypeError try: output = sp.check_output(["sha512sum", file], stderr=sp.PIPE) except sp.CalledProcessError as e: @@ -49,23 +50,25 @@ def track_file(file, base_dir): file_path = pathlib.Path(file).parts[-1] # Create Entry - entry_key = os.path.join(settings['etcd']['file_prefix'], str(file_id)) + entry_key = os.path.join( + settings["etcd"]["file_prefix"], str(file_id) + ) entry_value = { "filename": file_path, "owner": owner, "sha512sum": sha512sum(file), "creation_date": creation_date, - "size": os.path.getsize(file) + "size": os.path.getsize(file), } logger.info("Tracking %s", file) shared.etcd_client.put(entry_key, entry_value, value_in_json=True) - os.setxattr(file, 'user.utracked', b'True') + os.setxattr(file, "user.utracked", b"True") def main(): - base_dir = settings['storage']['file_dir'] + base_dir = settings["storage"]["file_dir"] # Recursively Get All Files and Folder below BASE_DIR files = glob.glob("{}/**".format(base_dir), recursive=True) @@ -76,7 +79,7 @@ def main(): untracked_files = [] for file in files: try: - os.getxattr(file, 'user.utracked') + os.getxattr(file, "user.utracked") except OSError: track_file(file, base_dir) untracked_files.append(file) diff --git a/ucloud/host/main.py b/ucloud/host/main.py index 904f26c..88dfb7c 100755 --- a/ucloud/host/main.py +++ b/ucloud/host/main.py @@ -15,49 +15,79 @@ from . import virtualmachine, logger def update_heartbeat(hostname): """Update Last HeartBeat Time for :param hostname: in etcd""" host_pool = shared.host_pool - this_host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) + this_host = next( + filter(lambda h: h.hostname == hostname, host_pool.hosts), None + ) while True: this_host.update_heartbeat() host_pool.put(this_host) time.sleep(10) -def maintenance(): +def maintenance(host): vmm = VMM() running_vms = vmm.discover() for vm_uuid in running_vms: - if vmm.is_running(vm_uuid) and vmm.get_status(vm_uuid) == 'running': - vm = shared.vm_pool.get(join_path(settings['etcd']['vm_prefix'], vm_uuid)) + if ( + vmm.is_running(vm_uuid) + and vmm.get_status(vm_uuid) == "running" + ): + vm = shared.vm_pool.get( + join_path(settings["etcd"]["vm_prefix"], vm_uuid) + ) vm.status = VMStatus.running + vm.vnc_socket = vmm.get_vnc(vm_uuid) + vm.hostname = host shared.vm_pool.put(vm) def main(hostname): host_pool = shared.host_pool - host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) - assert host is not None, "No such host with name = {}".format(hostname) + host = next( + filter(lambda h: h.hostname == hostname, host_pool.hosts), None + ) + assert host is not None, "No such host with name = {}".format( + hostname + ) try: - heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(hostname,)) + heartbeat_updating_process = mp.Process( + target=update_heartbeat, args=(hostname,) + ) heartbeat_updating_process.start() except Exception as e: - raise Exception('ucloud-host heartbeat updating mechanism is not working') from e + raise Exception( + "ucloud-host heartbeat updating mechanism is not working" + ) from e for events_iterator in [ - shared.etcd_client.get_prefix(settings['etcd']['request_prefix'], value_in_json=True), - shared.etcd_client.watch_prefix(settings['etcd']['request_prefix'], timeout=10, value_in_json=True), + shared.etcd_client.get_prefix( + settings["etcd"]["request_prefix"], value_in_json=True + ), + shared.etcd_client.watch_prefix( + settings["etcd"]["request_prefix"], + timeout=10, + value_in_json=True, + ), ]: for request_event in events_iterator: request_event = RequestEntry(request_event) if request_event.type == "TIMEOUT": - maintenance() + maintenance(host.key) if request_event.hostname == host.key: logger.debug("VM Request: %s", request_event) - shared.request_pool.client.client.delete(request_event.key) - vm_entry = shared.etcd_client.get(join_path(settings['etcd']['vm_prefix'], request_event.uuid)) + shared.request_pool.client.client.delete( + request_event.key + ) + vm_entry = shared.etcd_client.get( + join_path( + settings["etcd"]["vm_prefix"], + request_event.uuid, + ) + ) if vm_entry: vm = virtualmachine.VM(vm_entry) @@ -70,23 +100,35 @@ def main(hostname): elif request_event.type == RequestType.DeleteVM: vm.delete() - elif request_event.type == RequestType.InitVMMigration: + elif ( + request_event.type + == RequestType.InitVMMigration + ): vm.start(destination_host_key=host.key) elif request_event.type == RequestType.TransferVM: - host = host_pool.get(request_event.destination_host_key) + host = host_pool.get( + request_event.destination_host_key + ) if host: - vm.migrate(destination_host=host.hostname, - destination_sock_path=request_event.destination_sock_path) + vm.migrate( + destination_host=host.hostname, + destination_sock_path=request_event.destination_sock_path, + ) else: - logger.error('Host %s not found!', request_event.destination_host_key) + logger.error( + "Host %s not found!", + request_event.destination_host_key, + ) else: logger.info("VM Entry missing") if __name__ == "__main__": argparser = argparse.ArgumentParser() - argparser.add_argument("hostname", help="Name of this host. e.g uncloud1.ungleich.ch") + argparser.add_argument( + "hostname", help="Name of this host. e.g uncloud1.ungleich.ch" + ) args = argparser.parse_args() - mp.set_start_method('spawn') + mp.set_start_method("spawn") main(args.hostname) diff --git a/ucloud/host/qmp/__init__.py b/ucloud/host/qmp/__init__.py index 775b397..40ac3a4 100755 --- a/ucloud/host/qmp/__init__.py +++ b/ucloud/host/qmp/__init__.py @@ -26,10 +26,7 @@ LOG = logging.getLogger(__name__) # Mapping host architecture to any additional architectures it can # support which often includes its 32 bit cousin. -ADDITIONAL_ARCHES = { - "x86_64": "i386", - "aarch64": "armhf" -} +ADDITIONAL_ARCHES = {"x86_64": "i386", "aarch64": "armhf"} def kvm_available(target_arch=None): @@ -81,10 +78,17 @@ class QEMUMachine(object): # vm is guaranteed to be shut down here """ - def __init__(self, binary, args=None, wrapper=None, name=None, - test_dir="/var/tmp", monitor_address=None, - socket_scm_helper=None): - ''' + def __init__( + self, + binary, + args=None, + wrapper=None, + name=None, + test_dir="/var/tmp", + monitor_address=None, + socket_scm_helper=None, + ): + """ Initialize a QEMUMachine @param binary: path to the qemu binary @@ -95,7 +99,7 @@ class QEMUMachine(object): @param monitor_address: address for QMP monitor @param socket_scm_helper: helper program, required for send_fd_scm() @note: Qemu process is not started until launch() is used. - ''' + """ if args is None: args = [] if wrapper is None: @@ -109,7 +113,9 @@ class QEMUMachine(object): self._qemu_log_file = None self._popen = None self._binary = binary - self._args = list(args) # Force copy args in case we modify them + self._args = list( + args + ) # Force copy args in case we modify them self._wrapper = wrapper self._events = [] self._iolog = None @@ -137,26 +143,24 @@ class QEMUMachine(object): # This can be used to add an unused monitor instance. def add_monitor_null(self): - self._args.append('-monitor') - self._args.append('null') + self._args.append("-monitor") + self._args.append("null") - def add_fd(self, fd, fdset, opaque, opts=''): + def add_fd(self, fd, fdset, opaque, opts=""): """ Pass a file descriptor to the VM """ - options = ['fd=%d' % fd, - 'set=%d' % fdset, - 'opaque=%s' % opaque] + options = ["fd=%d" % fd, "set=%d" % fdset, "opaque=%s" % opaque] if opts: options.append(opts) # This did not exist before 3.4, but since then it is # mandatory for our purpose - if hasattr(os, 'set_inheritable'): + if hasattr(os, "set_inheritable"): os.set_inheritable(fd, True) - self._args.append('-add-fd') - self._args.append(','.join(options)) + self._args.append("-add-fd") + self._args.append(",".join(options)) return self # Exactly one of fd and file_path must be given. @@ -168,18 +172,21 @@ class QEMUMachine(object): if self._socket_scm_helper is None: raise QEMUMachineError("No path to socket_scm_helper set") if not os.path.exists(self._socket_scm_helper): - raise QEMUMachineError("%s does not exist" % - self._socket_scm_helper) + raise QEMUMachineError( + "%s does not exist" % self._socket_scm_helper + ) # This did not exist before 3.4, but since then it is # mandatory for our purpose - if hasattr(os, 'set_inheritable'): + if hasattr(os, "set_inheritable"): os.set_inheritable(self._qmp.get_sock_fd(), True) if fd is not None: os.set_inheritable(fd, True) - fd_param = ["%s" % self._socket_scm_helper, - "%d" % self._qmp.get_sock_fd()] + fd_param = [ + "%s" % self._socket_scm_helper, + "%d" % self._qmp.get_sock_fd(), + ] if file_path is not None: assert fd is None @@ -188,9 +195,14 @@ class QEMUMachine(object): assert fd is not None fd_param.append(str(fd)) - devnull = open(os.path.devnull, 'rb') - proc = subprocess.Popen(fd_param, stdin=devnull, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, close_fds=False) + devnull = open(os.path.devnull, "rb") + proc = subprocess.Popen( + fd_param, + stdin=devnull, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + close_fds=False, + ) output = proc.communicate()[0] if output: LOG.debug(output) @@ -231,24 +243,29 @@ class QEMUMachine(object): if isinstance(self._monitor_address, tuple): moncdev = "socket,id=mon,host=%s,port=%s" % ( self._monitor_address[0], - self._monitor_address[1]) + self._monitor_address[1], + ) else: - moncdev = 'socket,id=mon,path=%s' % self._vm_monitor - args = ['-chardev', moncdev, - '-mon', 'chardev=mon,mode=control'] + moncdev = "socket,id=mon,path=%s" % self._vm_monitor + args = ["-chardev", moncdev, "-mon", "chardev=mon,mode=control"] if self._machine is not None: - args.extend(['-machine', self._machine]) + args.extend(["-machine", self._machine]) if self._console_set: - self._console_address = os.path.join(self._temp_dir, - self._name + "-console.sock") - chardev = ('socket,id=console,path=%s,server,nowait' % - self._console_address) - args.extend(['-chardev', chardev]) + self._console_address = os.path.join( + self._temp_dir, self._name + "-console.sock" + ) + chardev = ( + "socket,id=console,path=%s,server,nowait" + % self._console_address + ) + args.extend(["-chardev", chardev]) if self._console_device_type is None: - args.extend(['-serial', 'chardev:console']) + args.extend(["-serial", "chardev:console"]) else: - device = '%s,chardev=console' % self._console_device_type - args.extend(['-device', device]) + device = ( + "%s,chardev=console" % self._console_device_type + ) + args.extend(["-device", device]) return args def _pre_launch(self): @@ -256,13 +273,17 @@ class QEMUMachine(object): if self._monitor_address is not None: self._vm_monitor = self._monitor_address else: - self._vm_monitor = os.path.join(self._temp_dir, - self._name + "-monitor.sock") - self._qemu_log_path = os.path.join(self._temp_dir, self._name + ".log") - self._qemu_log_file = open(self._qemu_log_path, 'wb') + self._vm_monitor = os.path.join( + self._temp_dir, self._name + "-monitor.sock" + ) + self._qemu_log_path = os.path.join( + self._temp_dir, self._name + ".log" + ) + self._qemu_log_file = open(self._qemu_log_path, "wb") - self._qmp = qmp.QEMUMonitorProtocol(self._vm_monitor, - server=True) + self._qmp = qmp.QEMUMonitorProtocol( + self._vm_monitor, server=True + ) def _post_launch(self): self._qmp.accept() @@ -289,7 +310,7 @@ class QEMUMachine(object): """ if self._launched: - raise QEMUMachineError('VM already launched') + raise QEMUMachineError("VM already launched") self._iolog = None self._qemu_full_args = None @@ -299,11 +320,11 @@ class QEMUMachine(object): except: self.shutdown() - LOG.debug('Error launching VM') + LOG.debug("Error launching VM") if self._qemu_full_args: - LOG.debug('Command: %r', ' '.join(self._qemu_full_args)) + LOG.debug("Command: %r", " ".join(self._qemu_full_args)) if self._iolog: - LOG.debug('Output: %r', self._iolog) + LOG.debug("Output: %r", self._iolog) raise Exception(self._iolog) raise @@ -311,17 +332,25 @@ class QEMUMachine(object): """ Launch the VM and establish a QMP connection """ - devnull = open(os.path.devnull, 'rb') + devnull = open(os.path.devnull, "rb") self._pre_launch() - self._qemu_full_args = (self._wrapper + [self._binary] + - self._base_args() + self._args) - LOG.debug('VM launch command: %r', ' '.join(self._qemu_full_args)) - self._popen = subprocess.Popen(self._qemu_full_args, - stdin=devnull, - stdout=self._qemu_log_file, - stderr=subprocess.STDOUT, - shell=False, - close_fds=False) + self._qemu_full_args = ( + self._wrapper + + [self._binary] + + self._base_args() + + self._args + ) + LOG.debug( + "VM launch command: %r", " ".join(self._qemu_full_args) + ) + self._popen = subprocess.Popen( + self._qemu_full_args, + stdin=devnull, + stdout=self._qemu_log_file, + stderr=subprocess.STDOUT, + shell=False, + close_fds=False, + ) self._post_launch() def wait(self): @@ -339,7 +368,7 @@ class QEMUMachine(object): """ if self.is_running(): try: - self._qmp.cmd('quit') + self._qmp.cmd("quit") self._qmp.close() except: self._popen.kill() @@ -350,11 +379,11 @@ class QEMUMachine(object): exitcode = self.exitcode() if exitcode is not None and exitcode < 0: - msg = 'qemu received signal %i: %s' + msg = "qemu received signal %i: %s" if self._qemu_full_args: - command = ' '.join(self._qemu_full_args) + command = " ".join(self._qemu_full_args) else: - command = '' + command = "" LOG.warn(msg, -exitcode, command) self._launched = False @@ -366,7 +395,7 @@ class QEMUMachine(object): qmp_args = dict() for key, value in args.items(): if conv_keys: - qmp_args[key.replace('_', '-')] = value + qmp_args[key.replace("_", "-")] = value else: qmp_args[key] = value @@ -427,7 +456,9 @@ class QEMUMachine(object): try: for key in match: if key in event: - if not QEMUMachine.event_match(event[key], match[key]): + if not QEMUMachine.event_match( + event[key], match[key] + ): return False else: return False @@ -458,8 +489,9 @@ class QEMUMachine(object): def _match(event): for name, match in events: - if (event['event'] == name and - self.event_match(event, match)): + if event["event"] == name and self.event_match( + event, match + ): return True return False @@ -531,7 +563,8 @@ class QEMUMachine(object): Returns a socket connected to the console """ if self._console_socket is None: - self._console_socket = socket.socket(socket.AF_UNIX, - socket.SOCK_STREAM) + self._console_socket = socket.socket( + socket.AF_UNIX, socket.SOCK_STREAM + ) self._console_socket.connect(self._console_address) return self._console_socket diff --git a/ucloud/host/qmp/qmp.py b/ucloud/host/qmp/qmp.py index bf35d71..ad187eb 100755 --- a/ucloud/host/qmp/qmp.py +++ b/ucloud/host/qmp/qmp.py @@ -32,7 +32,7 @@ class QMPTimeoutError(QMPError): class QEMUMonitorProtocol(object): #: Logger object for debugging messages - logger = logging.getLogger('QMP') + logger = logging.getLogger("QMP") #: Socket's error class error = socket.error #: Socket's timeout @@ -55,7 +55,9 @@ class QEMUMonitorProtocol(object): self.__sock = self.__get_sock() self.__sockfile = None if server: - self.__sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.__sock.setsockopt( + socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 + ) self.__sock.bind(self.__address) self.__sock.listen(1) @@ -71,7 +73,7 @@ class QEMUMonitorProtocol(object): if greeting is None or "QMP" not in greeting: raise QMPConnectError # Greeting seems ok, negotiate capabilities - resp = self.cmd('qmp_capabilities') + resp = self.cmd("qmp_capabilities") if "return" in resp: return greeting raise QMPCapabilitiesError @@ -82,7 +84,7 @@ class QEMUMonitorProtocol(object): if not data: return resp = json.loads(data) - if 'event' in resp: + if "event" in resp: self.logger.debug("<<< %s", resp) self.__events.append(resp) if not only_event: @@ -165,7 +167,7 @@ class QEMUMonitorProtocol(object): """ self.logger.debug(">>> %s", qmp_cmd) try: - self.__sock.sendall(json.dumps(qmp_cmd).encode('utf-8')) + self.__sock.sendall(json.dumps(qmp_cmd).encode("utf-8")) except socket.error as err: if err[0] == errno.EPIPE: return @@ -182,11 +184,11 @@ class QEMUMonitorProtocol(object): @param args: command arguments (dict) @param cmd_id: command id (dict, list, string or int) """ - qmp_cmd = {'execute': name} + qmp_cmd = {"execute": name} if args: - qmp_cmd['arguments'] = args + qmp_cmd["arguments"] = args if cmd_id: - qmp_cmd['id'] = cmd_id + qmp_cmd["id"] = cmd_id return self.cmd_obj(qmp_cmd) def command(self, cmd, **kwds): @@ -195,8 +197,8 @@ class QEMUMonitorProtocol(object): """ ret = self.cmd(cmd, kwds) if "error" in ret: - raise Exception(ret['error']['desc']) - return ret['return'] + raise Exception(ret["error"]["desc"]) + return ret["return"] def pull_event(self, wait=False): """ diff --git a/ucloud/host/virtualmachine.py b/ucloud/host/virtualmachine.py index db0f7b8..d795b3f 100755 --- a/ucloud/host/virtualmachine.py +++ b/ucloud/host/virtualmachine.py @@ -23,10 +23,6 @@ from ucloud.vmm import VMM from marshmallow import ValidationError -def maintenance(): - pass - - class VM: def __init__(self, vm_entry): self.schema = VMSchema() @@ -35,23 +31,30 @@ class VM: try: self.vm = self.schema.loads(vm_entry.value) except ValidationError: - logger.exception('Couldn\'t validate VM Entry', vm_entry.value) + logger.exception( + "Couldn't validate VM Entry", vm_entry.value + ) self.vm = None else: - self.uuid = vm_entry.key.split('/')[-1] - self.host_key = self.vm['hostname'] + self.uuid = vm_entry.key.split("/")[-1] + self.host_key = self.vm["hostname"] def get_qemu_args(self): command = ( - '-name {owner}_{name}' - ' -drive file={file},format=raw,if=virtio,cache=none' - ' -device virtio-rng-pci' - ' -m {memory} -smp cores={cores},threads={threads}' - ).format(owner=self.vm['owner'], name=self.vm['name'], - memory=int(self.vm['specs']['ram'].to_MB()), cores=self.vm['specs']['cpu'], - threads=1, file=shared.storage_handler.qemu_path_string(self.uuid)) + "-name {owner}_{name}" + " -drive file={file},format=raw,if=virtio,cache=none" + " -device virtio-rng-pci" + " -m {memory} -smp cores={cores},threads={threads}" + ).format( + owner=self.vm["owner"], + name=self.vm["name"], + memory=int(self.vm["specs"]["ram"].to_MB()), + cores=self.vm["specs"]["cpu"], + threads=1, + file=shared.storage_handler.qemu_path_string(self.uuid), + ) - return command.split(' ') + return command.split(" ") def start(self, destination_host_key=None): migration = False @@ -63,24 +66,34 @@ class VM: network_args = self.create_network_dev() except Exception as err: declare_stopped(self.vm) - self.vm['log'].append('Cannot Setup Network Properly') - logger.error('Cannot Setup Network Properly for vm %s', self.uuid, exc_info=err) + self.vm["log"].append("Cannot Setup Network Properly") + logger.error( + "Cannot Setup Network Properly for vm %s", + self.uuid, + exc_info=err, + ) else: - self.vmm.start(uuid=self.uuid, migration=migration, - *self.get_qemu_args(), *network_args) + self.vmm.start( + uuid=self.uuid, + migration=migration, + *self.get_qemu_args(), + *network_args + ) status = self.vmm.get_status(self.uuid) - if status == 'running': - self.vm['status'] = VMStatus.running - self.vm['vnc_socket'] = self.vmm.get_vnc(self.uuid) - elif status == 'inmigrate': + if status == "running": + self.vm["status"] = VMStatus.running + self.vm["vnc_socket"] = self.vmm.get_vnc(self.uuid) + elif status == "inmigrate": r = RequestEntry.from_scratch( type=RequestType.TransferVM, # Transfer VM hostname=self.host_key, # Which VM should get this request. It is source host uuid=self.uuid, # uuid of VM - destination_sock_path=join_path(self.vmm.socket_dir, self.uuid), + destination_sock_path=join_path( + self.vmm.socket_dir, self.uuid + ), destination_host_key=destination_host_key, # Where source host transfer VM - request_prefix=settings['etcd']['request_prefix'] + request_prefix=settings["etcd"]["request_prefix"], ) shared.request_pool.put(r) else: @@ -96,15 +109,22 @@ class VM: self.sync() def migrate(self, destination_host, destination_sock_path): - self.vmm.transfer(src_uuid=self.uuid, destination_sock_path=destination_sock_path, - host=destination_host) + self.vmm.transfer( + src_uuid=self.uuid, + destination_sock_path=destination_sock_path, + host=destination_host, + ) def create_network_dev(self): - command = '' - for network_mac_and_tap in self.vm['network']: + command = "" + for network_mac_and_tap in self.vm["network"]: network_name, mac, tap = network_mac_and_tap - _key = os.path.join(settings['etcd']['network_prefix'], self.vm['owner'], network_name) + _key = os.path.join( + settings["etcd"]["network_prefix"], + self.vm["owner"], + network_name, + ) network = shared.etcd_client.get(_key, value_in_json=True) network_schema = NetworkSchema() try: @@ -112,49 +132,64 @@ class VM: except ValidationError: continue - if network['type'] == "vxlan": - tap = create_vxlan_br_tap(_id=network['id'], - _dev=settings['network']['vxlan_phy_dev'], - tap_id=tap, - ip=network['ipv6']) + if network["type"] == "vxlan": + tap = create_vxlan_br_tap( + _id=network["id"], + _dev=settings["network"]["vxlan_phy_dev"], + tap_id=tap, + ip=network["ipv6"], + ) - all_networks = shared.etcd_client.get_prefix(settings['etcd']['network_prefix'], - value_in_json=True) + all_networks = shared.etcd_client.get_prefix( + settings["etcd"]["network_prefix"], + value_in_json=True, + ) - if ipaddress.ip_network(network['ipv6']).is_global: + if ipaddress.ip_network(network["ipv6"]).is_global: update_radvd_conf(all_networks) - command += '-netdev tap,id=vmnet{net_id},ifname={tap},script=no,downscript=no' \ - ' -device virtio-net-pci,netdev=vmnet{net_id},mac={mac}' \ - .format(tap=tap, net_id=network['id'], mac=mac) + command += ( + "-netdev tap,id=vmnet{net_id},ifname={tap},script=no,downscript=no" + " -device virtio-net-pci,netdev=vmnet{net_id},mac={mac}".format( + tap=tap, net_id=network["id"], mac=mac + ) + ) - return command.split(' ') + return command.split(" ") def delete_network_dev(self): try: - for network in self.vm['network']: + for network in self.vm["network"]: network_name = network[0] _ = network[1] # tap_mac tap_id = network[2] - delete_network_interface('tap{}'.format(tap_id)) + delete_network_interface("tap{}".format(tap_id)) - owners_vms = shared.vm_pool.by_owner(self.vm['owner']) - owners_running_vms = shared.vm_pool.by_status(VMStatus.running, - _vms=owners_vms) + owners_vms = shared.vm_pool.by_owner(self.vm["owner"]) + owners_running_vms = shared.vm_pool.by_status( + VMStatus.running, _vms=owners_vms + ) networks = map( - lambda n: n[0], map(lambda vm: vm.network, owners_running_vms) + lambda n: n[0], + map(lambda vm: vm.network, owners_running_vms), ) networks_in_use_by_user_vms = [vm[0] for vm in networks] if network_name not in networks_in_use_by_user_vms: - network_entry = resolve_network(network[0], self.vm['owner']) + network_entry = resolve_network( + network[0], self.vm["owner"] + ) if network_entry: network_type = network_entry.value["type"] network_id = network_entry.value["id"] if network_type == "vxlan": - delete_network_interface('br{}'.format(network_id)) - delete_network_interface('vxlan{}'.format(network_id)) + delete_network_interface( + "br{}".format(network_id) + ) + delete_network_interface( + "vxlan{}".format(network_id) + ) except Exception: logger.exception("Exception in network interface deletion") @@ -163,15 +198,21 @@ class VM: # File Already exists. No Problem Continue logger.debug("Image for vm %s exists", self.uuid) else: - if shared.storage_handler.make_vm_image(src=self.vm['image_uuid'], dest=self.uuid): - if not shared.storage_handler.resize_vm_image(path=self.uuid, - size=int(self.vm['specs']['os-ssd'].to_MB())): - self.vm['status'] = VMStatus.error + if shared.storage_handler.make_vm_image( + src=self.vm["image_uuid"], dest=self.uuid + ): + if not shared.storage_handler.resize_vm_image( + path=self.uuid, + size=int(self.vm["specs"]["os-ssd"].to_MB()), + ): + self.vm["status"] = VMStatus.error else: logger.info("New VM Created") def sync(self): - shared.etcd_client.put(self.key, self.schema.dump(self.vm), value_in_json=True) + shared.etcd_client.put( + self.key, self.schema.dump(self.vm), value_in_json=True + ) def delete(self): self.stop() @@ -186,50 +227,77 @@ class VM: def resolve_network(network_name, network_owner): network = shared.etcd_client.get( - join_path(settings['etcd']['network_prefix'], network_owner, network_name), value_in_json=True + join_path( + settings["etcd"]["network_prefix"], + network_owner, + network_name, + ), + value_in_json=True, ) return network def create_vxlan_br_tap(_id, _dev, tap_id, ip=None): - network_script_base = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'network') - vxlan = create_dev(script=os.path.join(network_script_base, 'create-vxlan.sh'), - _id=_id, dev=_dev) + network_script_base = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "network" + ) + vxlan = create_dev( + script=os.path.join(network_script_base, "create-vxlan.sh"), + _id=_id, + dev=_dev, + ) if vxlan: - bridge = create_dev(script=os.path.join(network_script_base, 'create-bridge.sh'), - _id=_id, dev=vxlan, ip=ip) + bridge = create_dev( + script=os.path.join( + network_script_base, "create-bridge.sh" + ), + _id=_id, + dev=vxlan, + ip=ip, + ) if bridge: - tap = create_dev(script=os.path.join(network_script_base, 'create-tap.sh'), - _id=str(tap_id), dev=bridge) + tap = create_dev( + script=os.path.join( + network_script_base, "create-tap.sh" + ), + _id=str(tap_id), + dev=bridge, + ) if tap: return tap def update_radvd_conf(all_networks): - network_script_base = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'network') + network_script_base = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "network" + ) networks = { - net.value['ipv6']: net.value['id'] + net.value["ipv6"]: net.value["id"] for net in all_networks - if net.value.get('ipv6') and ipaddress.ip_network(net.value.get('ipv6')).is_global + if net.value.get("ipv6") + and ipaddress.ip_network(net.value.get("ipv6")).is_global } - radvd_template = open(os.path.join(network_script_base, - 'radvd-template.conf'), 'r').read() + radvd_template = open( + os.path.join(network_script_base, "radvd-template.conf"), "r" + ).read() radvd_template = Template(radvd_template) content = [ radvd_template.safe_substitute( - bridge='br{}'.format(networks[net]), - prefix=net + bridge="br{}".format(networks[net]), prefix=net ) - for net in networks if networks.get(net) + for net in networks + if networks.get(net) ] - with open('/etc/radvd.conf', 'w') as radvd_conf: + with open("/etc/radvd.conf", "w") as radvd_conf: radvd_conf.writelines(content) try: - sp.check_output(['systemctl', 'restart', 'radvd']) + sp.check_output(["systemctl", "restart", "radvd"]) except sp.CalledProcessError: try: - sp.check_output(['service', 'radvd', 'restart']) + sp.check_output(["service", "radvd", "restart"]) except sp.CalledProcessError as err: - raise err.__class__('Cannot start/restart radvd service', err.cmd) from err + raise err.__class__( + "Cannot start/restart radvd service", err.cmd + ) from err diff --git a/ucloud/imagescanner/main.py b/ucloud/imagescanner/main.py index e215c88..e1960bc 100755 --- a/ucloud/imagescanner/main.py +++ b/ucloud/imagescanner/main.py @@ -9,7 +9,13 @@ from ucloud.imagescanner import logger def qemu_img_type(path): - qemu_img_info_command = ["qemu-img", "info", "--output", "json", path] + qemu_img_info_command = [ + "qemu-img", + "info", + "--output", + "json", + path, + ] try: qemu_img_info = sp.check_output(qemu_img_info_command) except Exception as e: @@ -22,32 +28,57 @@ def qemu_img_type(path): def main(): # We want to get images entries that requests images to be created - images = shared.etcd_client.get_prefix(settings['etcd']['image_prefix'], value_in_json=True) - images_to_be_created = list(filter(lambda im: im.value['status'] == 'TO_BE_CREATED', images)) + images = shared.etcd_client.get_prefix( + settings["etcd"]["image_prefix"], value_in_json=True + ) + images_to_be_created = list( + filter(lambda im: im.value["status"] == "TO_BE_CREATED", images) + ) for image in images_to_be_created: try: - image_uuid = image.key.split('/')[-1] - image_owner = image.value['owner'] - image_filename = image.value['filename'] - image_store_name = image.value['store_name'] - image_full_path = join_path(settings['storage']['file_dir'], image_owner, image_filename) + image_uuid = image.key.split("/")[-1] + image_owner = image.value["owner"] + image_filename = image.value["filename"] + image_store_name = image.value["store_name"] + image_full_path = join_path( + settings["storage"]["file_dir"], + image_owner, + image_filename, + ) - image_stores = shared.etcd_client.get_prefix(settings['etcd']['image_store_prefix'], - value_in_json=True) - user_image_store = next(filter( - lambda s, store_name=image_store_name: s.value["name"] == store_name, - image_stores - )) + image_stores = shared.etcd_client.get_prefix( + settings["etcd"]["image_store_prefix"], + value_in_json=True, + ) + user_image_store = next( + filter( + lambda s, store_name=image_store_name: s.value[ + "name" + ] + == store_name, + image_stores, + ) + ) - image_store_pool = user_image_store.value['attributes']['pool'] + image_store_pool = user_image_store.value["attributes"][ + "pool" + ] except Exception as e: logger.exception(e) else: # At least our basic data is available - qemu_img_convert_command = ["qemu-img", "convert", "-f", "qcow2", - "-O", "raw", image_full_path, "image.raw"] + qemu_img_convert_command = [ + "qemu-img", + "convert", + "-f", + "qcow2", + "-O", + "raw", + image_full_path, + "image.raw", + ] if qemu_img_type(image_full_path) == "qcow2": try: @@ -55,16 +86,20 @@ def main(): sp.check_output(qemu_img_convert_command,) except sp.CalledProcessError: - logger.exception('Image convertion from .qcow2 to .raw failed.') + logger.exception( + "Image convertion from .qcow2 to .raw failed." + ) else: # Import and Protect - r_status = shared.storage_handler.import_image(src="image.raw", - dest=image_uuid, - protect=True) + r_status = shared.storage_handler.import_image( + src="image.raw", dest=image_uuid, protect=True + ) if r_status: # Everything is successfully done image.value["status"] = "CREATED" - shared.etcd_client.put(image.key, json.dumps(image.value)) + shared.etcd_client.put( + image.key, json.dumps(image.value) + ) finally: try: os.remove("image.raw") @@ -74,7 +109,9 @@ def main(): else: # The user provided image is either not found or of invalid format image.value["status"] = "INVALID_IMAGE" - shared.etcd_client.put(image.key, json.dumps(image.value)) + shared.etcd_client.put( + image.key, json.dumps(image.value) + ) if __name__ == "__main__": diff --git a/ucloud/metadata/main.py b/ucloud/metadata/main.py index adec9e7..2974e33 100644 --- a/ucloud/metadata/main.py +++ b/ucloud/metadata/main.py @@ -21,33 +21,39 @@ def handle_exception(e): return e # now you're handling non-HTTP exceptions only - return {'message': 'Server Error'}, 500 + return {"message": "Server Error"}, 500 def get_vm_entry(mac_addr): - return next(filter(lambda vm: mac_addr in list(zip(*vm.network))[1], shared.vm_pool.vms), None) + return next( + filter( + lambda vm: mac_addr in list(zip(*vm.network))[1], + shared.vm_pool.vms, + ), + None, + ) # https://stackoverflow.com/questions/37140846/how-to-convert-ipv6-link-local-address-to-mac-address-in-python def ipv62mac(ipv6): # remove subnet info if given - subnet_index = ipv6.find('/') + subnet_index = ipv6.find("/") if subnet_index != -1: ipv6 = ipv6[:subnet_index] - ipv6_parts = ipv6.split(':') + ipv6_parts = ipv6.split(":") mac_parts = list() for ipv6_part in ipv6_parts[-4:]: while len(ipv6_part) < 4: - ipv6_part = '0' + ipv6_part + ipv6_part = "0" + ipv6_part mac_parts.append(ipv6_part[:2]) mac_parts.append(ipv6_part[-2:]) # modify parts to match MAC value - mac_parts[0] = '%02x' % (int(mac_parts[0], 16) ^ 2) + mac_parts[0] = "%02x" % (int(mac_parts[0], 16) ^ 2) del mac_parts[4] del mac_parts[3] - return ':'.join(mac_parts) + return ":".join(mac_parts) class Root(Resource): @@ -56,19 +62,27 @@ class Root(Resource): data = get_vm_entry(ipv62mac(request.remote_addr)) if not data: - return {'message': 'Metadata for such VM does not exists.'}, 404 + return ( + {"message": "Metadata for such VM does not exists."}, + 404, + ) else: - etcd_key = os.path.join(settings['etcd']['user_prefix'], - data.value['owner_realm'], - data.value['owner'], 'key') - etcd_entry = shared.etcd_client.get_prefix(etcd_key, value_in_json=True) + etcd_key = os.path.join( + settings["etcd"]["user_prefix"], + data.value["owner_realm"], + data.value["owner"], + "key", + ) + etcd_entry = shared.etcd_client.get_prefix( + etcd_key, value_in_json=True + ) user_personal_ssh_keys = [key.value for key in etcd_entry] - data.value['metadata']['ssh-keys'] += user_personal_ssh_keys - return data.value['metadata'], 200 + data.value["metadata"]["ssh-keys"] += user_personal_ssh_keys + return data.value["metadata"], 200 @staticmethod def post(): - return {'message': 'Previous Implementation is deprecated.'} + return {"message": "Previous Implementation is deprecated."} # data = etcd_client.get("/v1/metadata/{}".format(request.remote_addr), value_in_json=True) # print(data) # if data: @@ -94,12 +108,12 @@ class Root(Resource): # data, value_in_json=True) -api.add_resource(Root, '/') +api.add_resource(Root, "/") def main(): app.run(debug=True, host="::", port="80") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/ucloud/scheduler/__init__.py b/ucloud/scheduler/__init__.py index 95e1be0..eea436a 100644 --- a/ucloud/scheduler/__init__.py +++ b/ucloud/scheduler/__init__.py @@ -1,3 +1,3 @@ import logging -logger = logging.getLogger(__name__) \ No newline at end of file +logger = logging.getLogger(__name__) diff --git a/ucloud/scheduler/helper.py b/ucloud/scheduler/helper.py index 0e9ef73..2fb7a22 100755 --- a/ucloud/scheduler/helper.py +++ b/ucloud/scheduler/helper.py @@ -24,17 +24,35 @@ def remaining_resources(host_specs, vms_specs): for component in _vms_specs: if isinstance(_vms_specs[component], str): - _vms_specs[component] = int(bitmath.parse_string_unsafe(_vms_specs[component]).to_MB()) + _vms_specs[component] = int( + bitmath.parse_string_unsafe( + _vms_specs[component] + ).to_MB() + ) elif isinstance(_vms_specs[component], list): - _vms_specs[component] = map(lambda x: int(bitmath.parse_string_unsafe(x).to_MB()), _vms_specs[component]) - _vms_specs[component] = reduce(lambda x, y: x + y, _vms_specs[component], 0) + _vms_specs[component] = map( + lambda x: int(bitmath.parse_string_unsafe(x).to_MB()), + _vms_specs[component], + ) + _vms_specs[component] = reduce( + lambda x, y: x + y, _vms_specs[component], 0 + ) for component in _remaining: if isinstance(_remaining[component], str): - _remaining[component] = int(bitmath.parse_string_unsafe(_remaining[component]).to_MB()) + _remaining[component] = int( + bitmath.parse_string_unsafe( + _remaining[component] + ).to_MB() + ) elif isinstance(_remaining[component], list): - _remaining[component] = map(lambda x: int(bitmath.parse_string_unsafe(x).to_MB()), _remaining[component]) - _remaining[component] = reduce(lambda x, y: x + y, _remaining[component], 0) + _remaining[component] = map( + lambda x: int(bitmath.parse_string_unsafe(x).to_MB()), + _remaining[component], + ) + _remaining[component] = reduce( + lambda x, y: x + y, _remaining[component], 0 + ) _remaining.subtract(_vms_specs) @@ -59,11 +77,15 @@ def get_suitable_host(vm_specs, hosts=None): running_vms_specs = [vm.specs for vm in vms] # Accumulate all of their combined specs - running_vms_accumulated_specs = accumulated_specs(running_vms_specs) + running_vms_accumulated_specs = accumulated_specs( + running_vms_specs + ) # Find out remaining resources after # host_specs - already running vm_specs - remaining = remaining_resources(host.specs, running_vms_accumulated_specs) + remaining = remaining_resources( + host.specs, running_vms_accumulated_specs + ) # Find out remaining - new_vm_specs remaining = remaining_resources(remaining, vm_specs) @@ -95,7 +117,7 @@ def dead_host_mitigation(dead_hosts_keys): vms_hosted_on_dead_host = shared.vm_pool.by_host(host_key) for vm in vms_hosted_on_dead_host: - vm.status = 'UNKNOWN' + vm.status = "UNKNOWN" shared.vm_pool.put(vm) shared.host_pool.put(host) @@ -104,10 +126,12 @@ def assign_host(vm): vm.hostname = get_suitable_host(vm.specs) shared.vm_pool.put(vm) - r = RequestEntry.from_scratch(type=RequestType.StartVM, - uuid=vm.uuid, - hostname=vm.hostname, - request_prefix=settings['etcd']['request_prefix']) + r = RequestEntry.from_scratch( + type=RequestType.StartVM, + uuid=vm.uuid, + hostname=vm.hostname, + request_prefix=settings["etcd"]["request_prefix"], + ) shared.request_pool.put(r) vm.log.append("VM scheduled for starting") diff --git a/ucloud/scheduler/main.py b/ucloud/scheduler/main.py index d91979f..7ee75e0 100755 --- a/ucloud/scheduler/main.py +++ b/ucloud/scheduler/main.py @@ -7,8 +7,13 @@ from ucloud.common.request import RequestEntry, RequestType from ucloud.shared import shared from ucloud.settings import settings -from .helper import (get_suitable_host, dead_host_mitigation, dead_host_detection, - assign_host, NoSuitableHostFound) +from .helper import ( + get_suitable_host, + dead_host_mitigation, + dead_host_detection, + assign_host, + NoSuitableHostFound, +) from . import logger @@ -16,8 +21,14 @@ def main(): pending_vms = [] for request_iterator in [ - shared.etcd_client.get_prefix(settings['etcd']['request_prefix'], value_in_json=True), - shared.etcd_client.watch_prefix(settings['etcd']['request_prefix'], timeout=5, value_in_json=True), + shared.etcd_client.get_prefix( + settings["etcd"]["request_prefix"], value_in_json=True + ), + shared.etcd_client.watch_prefix( + settings["etcd"]["request_prefix"], + timeout=5, + value_in_json=True, + ), ]: for request_event in request_iterator: request_entry = RequestEntry(request_event) @@ -41,25 +52,39 @@ def main(): # on our behalf. while pending_vms: pending_vm_entry = pending_vms.pop() - r = RequestEntry.from_scratch(type="ScheduleVM", - uuid=pending_vm_entry.uuid, - hostname=pending_vm_entry.hostname, - request_prefix=settings['etcd']['request_prefix']) + r = RequestEntry.from_scratch( + type="ScheduleVM", + uuid=pending_vm_entry.uuid, + hostname=pending_vm_entry.hostname, + request_prefix=settings["etcd"][ + "request_prefix" + ], + ) shared.request_pool.put(r) elif request_entry.type == RequestType.ScheduleVM: - logger.debug("%s, %s", request_entry.key, request_entry.value) + logger.debug( + "%s, %s", request_entry.key, request_entry.value + ) vm_entry = shared.vm_pool.get(request_entry.uuid) if vm_entry is None: - logger.info("Trying to act on {} but it is deleted".format(request_entry.uuid)) + logger.info( + "Trying to act on {} but it is deleted".format( + request_entry.uuid + ) + ) continue - shared.etcd_client.client.delete(request_entry.key) # consume Request + shared.etcd_client.client.delete( + request_entry.key + ) # consume Request try: assign_host(vm_entry) except NoSuitableHostFound: - vm_entry.add_log("Can't schedule VM. No Resource Left.") + vm_entry.add_log( + "Can't schedule VM. No Resource Left." + ) shared.vm_pool.put(vm_entry) pending_vms.append(vm_entry) diff --git a/ucloud/scheduler/tests/test_basics.py b/ucloud/scheduler/tests/test_basics.py index 92b3a83..68bd8ec 100755 --- a/ucloud/scheduler/tests/test_basics.py +++ b/ucloud/scheduler/tests/test_basics.py @@ -70,9 +70,15 @@ class TestFunctions(unittest.TestCase): "last_heartbeat": datetime.utcnow().isoformat(), } with self.client.client.lock("lock"): - self.client.put(f"{self.host_prefix}/1", host1, value_in_json=True) - self.client.put(f"{self.host_prefix}/2", host2, value_in_json=True) - self.client.put(f"{self.host_prefix}/3", host3, value_in_json=True) + self.client.put( + f"{self.host_prefix}/1", host1, value_in_json=True + ) + self.client.put( + f"{self.host_prefix}/2", host2, value_in_json=True + ) + self.client.put( + f"{self.host_prefix}/3", host3, value_in_json=True + ) def create_vms(self): vm1 = json.dumps( @@ -146,15 +152,17 @@ class TestFunctions(unittest.TestCase): {"cpu": 8, "ram": 32}, ] self.assertEqual( - accumulated_specs(vms), {"ssd": 10, "cpu": 16, "ram": 48, "hdd": 10} + accumulated_specs(vms), + {"ssd": 10, "cpu": 16, "ram": 48, "hdd": 10}, ) def test_remaining_resources(self): host_specs = {"ssd": 10, "cpu": 16, "ram": 48, "hdd": 10} vms_specs = {"ssd": 10, "cpu": 32, "ram": 12, "hdd": 0} resultant_specs = {"ssd": 0, "cpu": -16, "ram": 36, "hdd": 10} - self.assertEqual(remaining_resources(host_specs, vms_specs), - resultant_specs) + self.assertEqual( + remaining_resources(host_specs, vms_specs), resultant_specs + ) def test_vmpool(self): self.p.join(1) @@ -167,7 +175,12 @@ class TestFunctions(unittest.TestCase): f"{self.vm_prefix}/1", { "owner": "meow", - "specs": {"cpu": 4, "ram": 8, "hdd": 100, "sdd": 256}, + "specs": { + "cpu": 4, + "ram": 8, + "hdd": 100, + "sdd": 256, + }, "hostname": f"{self.host_prefix}/3", "status": "SCHEDULED_DEPLOY", }, @@ -182,7 +195,12 @@ class TestFunctions(unittest.TestCase): f"{self.vm_prefix}/7", { "owner": "meow", - "specs": {"cpu": 10, "ram": 22, "hdd": 146, "sdd": 0}, + "specs": { + "cpu": 10, + "ram": 22, + "hdd": 146, + "sdd": 0, + }, "hostname": "", "status": "REQUESTED_NEW", }, @@ -197,7 +215,12 @@ class TestFunctions(unittest.TestCase): f"{self.vm_prefix}/7", { "owner": "meow", - "specs": {"cpu": 10, "ram": 22, "hdd": 146, "sdd": 0}, + "specs": { + "cpu": 10, + "ram": 22, + "hdd": 146, + "sdd": 0, + }, "hostname": "", "status": "REQUESTED_NEW", }, diff --git a/ucloud/scheduler/tests/test_dead_host_mechanism.py b/ucloud/scheduler/tests/test_dead_host_mechanism.py index 0b403ef..466b9ee 100755 --- a/ucloud/scheduler/tests/test_dead_host_mechanism.py +++ b/ucloud/scheduler/tests/test_dead_host_mechanism.py @@ -6,11 +6,7 @@ from os.path import dirname BASE_DIR = dirname(dirname(__file__)) sys.path.insert(0, BASE_DIR) -from main import ( - dead_host_detection, - dead_host_mitigation, - config -) +from main import dead_host_detection, dead_host_mitigation, config class TestDeadHostMechanism(unittest.TestCase): @@ -52,13 +48,23 @@ class TestDeadHostMechanism(unittest.TestCase): "last_heartbeat": datetime(2011, 1, 1).isoformat(), } with self.client.client.lock("lock"): - self.client.put(f"{self.host_prefix}/1", host1, value_in_json=True) - self.client.put(f"{self.host_prefix}/2", host2, value_in_json=True) - self.client.put(f"{self.host_prefix}/3", host3, value_in_json=True) - self.client.put(f"{self.host_prefix}/4", host4, value_in_json=True) + self.client.put( + f"{self.host_prefix}/1", host1, value_in_json=True + ) + self.client.put( + f"{self.host_prefix}/2", host2, value_in_json=True + ) + self.client.put( + f"{self.host_prefix}/3", host3, value_in_json=True + ) + self.client.put( + f"{self.host_prefix}/4", host4, value_in_json=True + ) def test_dead_host_detection(self): - hosts = self.client.get_prefix(self.host_prefix, value_in_json=True) + hosts = self.client.get_prefix( + self.host_prefix, value_in_json=True + ) deads = dead_host_detection(hosts) self.assertEqual(deads, ["/test/host/2", "/test/host/3"]) return deads @@ -66,7 +72,9 @@ class TestDeadHostMechanism(unittest.TestCase): def test_dead_host_mitigation(self): deads = self.test_dead_host_detection() dead_host_mitigation(self.client, deads) - hosts = self.client.get_prefix(self.host_prefix, value_in_json=True) + hosts = self.client.get_prefix( + self.host_prefix, value_in_json=True + ) deads = dead_host_detection(hosts) self.assertEqual(deads, []) diff --git a/ucloud/settings/__init__.py b/ucloud/settings/__init__.py index f9b358e..906e857 100644 --- a/ucloud/settings/__init__.py +++ b/ucloud/settings/__init__.py @@ -14,18 +14,22 @@ class CustomConfigParser(configparser.RawConfigParser): result = super().__getitem__(key) except KeyError as err: raise KeyError( - 'Key \'{}\' not found in configuration. Make sure you configure ucloud.'.format(key) + "Key '{}' not found in configuration. Make sure you configure ucloud.".format( + key + ) ) from err else: return result class Settings(object): - def __init__(self, config_key='/uncloud/config/'): - conf_name = 'ucloud.conf' - conf_dir = os.environ.get('UCLOUD_CONF_DIR', os.path.expanduser('~/ucloud/')) + def __init__(self, config_key="/uncloud/config/"): + conf_name = "ucloud.conf" + conf_dir = os.environ.get( + "UCLOUD_CONF_DIR", os.path.expanduser("~/ucloud/") + ) self.config_file = os.path.join(conf_dir, conf_name) - + self.config_parser = CustomConfigParser(allow_no_value=True) self.config_key = config_key @@ -33,43 +37,55 @@ class Settings(object): try: self.config_parser.read(self.config_file) except Exception as err: - logger.error('%s', err) + logger.error("%s", err) def get_etcd_client(self): args = tuple() try: kwargs = { - 'host': self.config_parser.get('etcd', 'url'), - 'port': self.config_parser.get('etcd', 'port'), - 'ca_cert': self.config_parser.get('etcd', 'ca_cert'), - 'cert_cert': self.config_parser.get('etcd', 'cert_cert'), - 'cert_key': self.config_parser.get('etcd', 'cert_key') + "host": self.config_parser.get("etcd", "url"), + "port": self.config_parser.get("etcd", "port"), + "ca_cert": self.config_parser.get("etcd", "ca_cert"), + "cert_cert": self.config_parser.get( + "etcd", "cert_cert" + ), + "cert_key": self.config_parser.get("etcd", "cert_key"), } except configparser.Error as err: - raise configparser.Error('{} in config file {}'.format(err.message, self.config_file)) from err + raise configparser.Error( + "{} in config file {}".format( + err.message, self.config_file + ) + ) from err else: try: wrapper = Etcd3Wrapper(*args, **kwargs) except Exception as err: - logger.error('etcd connection not successfull. Please check your config file.' - '\nDetails: %s\netcd connection parameters: %s', err, kwargs) + logger.error( + "etcd connection not successfull. Please check your config file." + "\nDetails: %s\netcd connection parameters: %s", + err, + kwargs, + ) sys.exit(1) else: return wrapper - + def read_internal_values(self): - self.config_parser.read_dict({ - 'etcd': { - 'file_prefix': '/files/', - 'host_prefix': '/hosts/', - 'image_prefix': '/images/', - 'image_store_prefix': '/imagestore/', - 'network_prefix': '/networks/', - 'request_prefix': '/requests/', - 'user_prefix': '/users/', - 'vm_prefix': '/vms/', + self.config_parser.read_dict( + { + "etcd": { + "file_prefix": "/files/", + "host_prefix": "/hosts/", + "image_prefix": "/images/", + "image_store_prefix": "/imagestore/", + "network_prefix": "/networks/", + "request_prefix": "/requests/", + "user_prefix": "/users/", + "vm_prefix": "/vms/", + } } - }) + ) def read_config_file_values(self, config_file): try: @@ -77,18 +93,26 @@ class Settings(object): with open(config_file, "r") as config_file_handle: self.config_parser.read_file(config_file_handle) except FileNotFoundError: - sys.exit('Configuration file {} not found!'.format(config_file)) + sys.exit( + "Configuration file {} not found!".format(config_file) + ) except Exception as err: logger.exception(err) sys.exit("Error occurred while reading configuration file") def read_values_from_etcd(self): etcd_client = self.get_etcd_client() - config_from_etcd = etcd_client.get(self.config_key, value_in_json=True) + config_from_etcd = etcd_client.get( + self.config_key, value_in_json=True + ) if config_from_etcd: self.config_parser.read_dict(config_from_etcd.value) else: - raise KeyError("Key '{}' not found in etcd. Please configure ucloud.".format(self.config_key)) + raise KeyError( + "Key '{}' not found in etcd. Please configure ucloud.".format( + self.config_key + ) + ) def __getitem__(self, key): self.read_values_from_etcd() diff --git a/ucloud/shared/__init__.py b/ucloud/shared/__init__.py index 7a296e9..294e34a 100644 --- a/ucloud/shared/__init__.py +++ b/ucloud/shared/__init__.py @@ -12,15 +12,19 @@ class Shared: @property def host_pool(self): - return HostPool(self.etcd_client, settings['etcd']['host_prefix']) + return HostPool( + self.etcd_client, settings["etcd"]["host_prefix"] + ) @property def vm_pool(self): - return VmPool(self.etcd_client, settings['etcd']['vm_prefix']) + return VmPool(self.etcd_client, settings["etcd"]["vm_prefix"]) @property def request_pool(self): - return RequestPool(self.etcd_client, settings['etcd']['request_prefix']) + return RequestPool( + self.etcd_client, settings["etcd"]["request_prefix"] + ) @property def storage_handler(self): diff --git a/ucloud/vmm/__init__.py b/ucloud/vmm/__init__.py index 9f9f5f9..3d3c304 100644 --- a/ucloud/vmm/__init__.py +++ b/ucloud/vmm/__init__.py @@ -37,7 +37,9 @@ class VMQMPHandles: self.sock.close() if exc_type: - logger.error('Couldn\'t get handle for VM.', exc_type, exc_val, exc_tb) + logger.error( + "Couldn't get handle for VM.", exc_type, exc_val, exc_tb + ) raise exc_type("Couldn't get handle for VM.") from exc_type @@ -54,29 +56,46 @@ class TransferVM(Process): with suppress(FileNotFoundError): os.remove(self.src_sock_path) - command = ['ssh', '-nNT', '-L', '{}:{}'.format(self.src_sock_path, self.dest_sock_path), - 'root@{}'.format(self.host)] + command = [ + "ssh", + "-nNT", + "-L", + "{}:{}".format(self.src_sock_path, self.dest_sock_path), + "root@{}".format(self.host), + ] try: p = sp.Popen(command) except Exception as e: - logger.error('Couldn\' forward unix socks over ssh.', exc_info=e) + logger.error( + "Couldn' forward unix socks over ssh.", exc_info=e + ) else: time.sleep(2) vmm = VMM() - logger.debug('Executing: ssh forwarding command: %s', command) - vmm.execute_command(self.src_uuid, command='migrate', - arguments={'uri': 'unix:{}'.format(self.src_sock_path)}) + logger.debug( + "Executing: ssh forwarding command: %s", command + ) + vmm.execute_command( + self.src_uuid, + command="migrate", + arguments={"uri": "unix:{}".format(self.src_sock_path)}, + ) while p.poll() is None: - success, output = vmm.execute_command(self.src_uuid, command='query-migrate') + success, output = vmm.execute_command( + self.src_uuid, command="query-migrate" + ) if success: - status = output['return']['status'] - if status != 'active': - print('Migration Status: ', status) + status = output["return"]["status"] + + if status != "active": + print("Migration Status: ", status) + if status == "completed": + vmm.stop(self.src_uuid) return else: - print('Migration Status: ', status) + print("Migration Status: ", status) else: return time.sleep(0.2) @@ -84,18 +103,29 @@ class TransferVM(Process): class VMM: # Virtual Machine Manager - def __init__(self, qemu_path='/usr/bin/qemu-system-x86_64', - vmm_backend=os.path.expanduser('~/ucloud/vmm/')): + def __init__( + self, + qemu_path="/usr/bin/qemu-system-x86_64", + vmm_backend=os.path.expanduser("~/ucloud/vmm/"), + ): self.qemu_path = qemu_path self.vmm_backend = vmm_backend - self.socket_dir = os.path.join(self.vmm_backend, 'sock') + self.socket_dir = os.path.join(self.vmm_backend, "sock") if not os.path.isdir(self.vmm_backend): - logger.info('{} does not exists. Creating it...'.format(self.vmm_backend)) + logger.info( + "{} does not exists. Creating it...".format( + self.vmm_backend + ) + ) os.makedirs(self.vmm_backend, exist_ok=True) if not os.path.isdir(self.socket_dir): - logger.info('{} does not exists. Creating it...'.format(self.socket_dir)) + logger.info( + "{} does not exists. Creating it...".format( + self.socket_dir + ) + ) os.makedirs(self.socket_dir, exist_ok=True) def is_running(self, uuid): @@ -106,8 +136,12 @@ class VMM: recv = sock.recv(4096) except Exception as err: # unix sock doesn't exists or it is closed - logger.debug('VM {} sock either don\' exists or it is closed. It mean VM is stopped.'.format(uuid), - exc_info=err) + logger.debug( + "VM {} sock either don' exists or it is closed. It mean VM is stopped.".format( + uuid + ), + exc_info=err, + ) else: # if we receive greetings from qmp it mean VM is running if len(recv) > 0: @@ -122,36 +156,67 @@ class VMM: # start --> sucess? migration_args = () if migration: - migration_args = ('-incoming', 'unix:{}'.format(os.path.join(self.socket_dir, uuid))) + migration_args = ( + "-incoming", + "unix:{}".format(os.path.join(self.socket_dir, uuid)), + ) if self.is_running(uuid): - logger.warning('Cannot start VM. It is already running.') + logger.warning("Cannot start VM. It is already running.") else: - qmp_arg = ('-qmp', 'unix:{},server,nowait'.format(join_path(self.vmm_backend, uuid))) - vnc_arg = ('-vnc', 'unix:{}'.format(tempfile.NamedTemporaryFile().name)) + qmp_arg = ( + "-qmp", + "unix:{},server,nowait".format( + join_path(self.vmm_backend, uuid) + ), + ) + vnc_arg = ( + "-vnc", + "unix:{}".format(tempfile.NamedTemporaryFile().name), + ) - command = ['sudo', '-p', 'Enter password to start VM {}: '.format(uuid), - self.qemu_path, *args, *qmp_arg, *migration_args, *vnc_arg, '-daemonize'] + command = [ + "sudo", + "-p", + "Enter password to start VM {}: ".format(uuid), + self.qemu_path, + *args, + *qmp_arg, + *migration_args, + *vnc_arg, + "-daemonize", + ] try: sp.check_output(command, stderr=sp.PIPE) except sp.CalledProcessError as err: - logger.exception('Error occurred while starting VM.\nDetail %s', err.stderr.decode('utf-8')) + logger.exception( + "Error occurred while starting VM.\nDetail %s", + err.stderr.decode("utf-8"), + ) else: with suppress(sp.CalledProcessError): - sp.check_output([ - 'sudo', '-p', - 'Enter password to correct permission for uncloud-vmm\'s directory', - 'chmod', '-R', 'o=rwx,g=rwx', self.vmm_backend - ]) + sp.check_output( + [ + "sudo", + "-p", + "Enter password to correct permission for uncloud-vmm's directory", + "chmod", + "-R", + "o=rwx,g=rwx", + self.vmm_backend, + ] + ) # TODO: Find some good way to check whether the virtual machine is up and # running without relying on non-guarenteed ways. for _ in range(10): time.sleep(2) status = self.get_status(uuid) - if status in ['running', 'inmigrate']: + if status in ["running", "inmigrate"]: return status - logger.warning('Timeout on VM\'s status. Shutting down VM %s', uuid) + logger.warning( + "Timeout on VM's status. Shutting down VM %s", uuid + ) self.stop(uuid) # TODO: What should we do more. VM can still continue to run in background. # If we have pid of vm we can kill it using OS. @@ -159,55 +224,73 @@ class VMM: def execute_command(self, uuid, command, **kwargs): # execute_command -> sucess?, output try: - with VMQMPHandles(os.path.join(self.vmm_backend, uuid)) as (sock_handle, file_handle): - command_to_execute = { - 'execute': command, - **kwargs - } - sock_handle.sendall(json.dumps(command_to_execute).encode('utf-8')) + with VMQMPHandles(os.path.join(self.vmm_backend, uuid)) as ( + sock_handle, + file_handle, + ): + command_to_execute = {"execute": command, **kwargs} + sock_handle.sendall( + json.dumps(command_to_execute).encode("utf-8") + ) output = file_handle.readline() except Exception: - logger.exception('Error occurred while executing command and getting valid output from qmp') + logger.exception( + "Error occurred while executing command and getting valid output from qmp" + ) else: try: output = json.loads(output) except Exception: - logger.exception('QMP Output isn\'t valid JSON. %s', output) + logger.exception( + "QMP Output isn't valid JSON. %s", output + ) else: - return 'return' in output, output + return "return" in output, output return False, None def stop(self, uuid): - success, output = self.execute_command(command='quit', uuid=uuid) + success, output = self.execute_command( + command="quit", uuid=uuid + ) return success def get_status(self, uuid): - success, output = self.execute_command(command='query-status', uuid=uuid) + success, output = self.execute_command( + command="query-status", uuid=uuid + ) if success: - return output['return']['status'] + return output["return"]["status"] else: # TODO: Think about this for a little more - return 'STOPPED' + return "STOPPED" def discover(self): vms = [ - uuid for uuid in os.listdir(self.vmm_backend) + uuid + for uuid in os.listdir(self.vmm_backend) if not isdir(join_path(self.vmm_backend, uuid)) ] return vms def get_vnc(self, uuid): - success, output = self.execute_command(uuid, command='query-vnc') + success, output = self.execute_command( + uuid, command="query-vnc" + ) if success: - return output['return']['service'] + return output["return"]["service"] return None def transfer(self, src_uuid, destination_sock_path, host): - p = TransferVM(src_uuid, destination_sock_path, socket_dir=self.socket_dir, host=host) + p = TransferVM( + src_uuid, + destination_sock_path, + socket_dir=self.socket_dir, + host=host, + ) p.start() # TODO: the following method should clean things that went wrong # e.g If VM migration fails or didn't start for long time # i.e 15 minutes we should stop the waiting VM. def maintenace(self): - pass \ No newline at end of file + pass From 52867614df4923fc06bd94a8295636390d67af20 Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 30 Dec 2019 14:58:05 +0500 Subject: [PATCH 091/284] Remove unused code + Increase frequeuncy of host heartbeat update --- ucloud/api/helper.py | 32 -- ucloud/api/main.py | 9 - ucloud/api/schemas.py | 3 - ucloud/common/helpers.py | 40 --- ucloud/common/network.py | 12 - ucloud/host/main.py | 2 +- ucloud/host/qmp/__init__.py | 570 ------------------------------------ ucloud/host/qmp/qmp.py | 257 ---------------- 8 files changed, 1 insertion(+), 924 deletions(-) delete mode 100644 ucloud/common/helpers.py delete mode 100755 ucloud/host/qmp/__init__.py delete mode 100755 ucloud/host/qmp/qmp.py diff --git a/ucloud/api/helper.py b/ucloud/api/helper.py index a77a151..6fdeb30 100755 --- a/ucloud/api/helper.py +++ b/ucloud/api/helper.py @@ -132,38 +132,6 @@ def generate_mac( return separator.join(byte_fmt % b for b in mac) -def get_ip_addr(mac_address, device): - """Return IP address of a device provided its mac address / link local address - and the device with which it is connected. - - For Example, if we call get_ip_addr(mac_address="52:54:00:12:34:56", device="br0") - the following two scenarios can happen - 1. It would return None if we can't be able to find device whose mac_address is equal - to the arg:mac_address or the mentioned arg:device does not exists or the ip address - we found is local. - 2. It would return ip_address of device whose mac_address is equal to arg:mac_address - and is connected/neighbor of arg:device - """ - try: - output = sp.check_output( - ["ip", "-6", "neigh", "show", "dev", device], stderr=sp.PIPE - ) - except sp.CalledProcessError: - return None - else: - result = [] - output = output.strip().decode("utf-8") - output = output.split("\n") - for entry in output: - entry = entry.split() - if entry: - ip = ipaddress.ip_address(entry[0]) - mac = entry[2] - if ip.is_global and mac_address == mac: - result.append(ip) - return result - - def mac2ipv6(mac, prefix): # only accept MACs separated by a colon parts = mac.split(":") diff --git a/ucloud/api/main.py b/ucloud/api/main.py index c63babf..d4cdbe9 100644 --- a/ucloud/api/main.py +++ b/ucloud/api/main.py @@ -19,15 +19,6 @@ from . import schemas from .helper import generate_mac, mac2ipv6 -def get_parent(obj, attr): - parent = getattr(obj, attr) - child = parent - while parent is not None: - child = parent - parent = getattr(parent, attr) - return child - - logger = logging.getLogger(__name__) app = Flask(__name__) diff --git a/ucloud/api/schemas.py b/ucloud/api/schemas.py index a848a7d..91289b0 100755 --- a/ucloud/api/schemas.py +++ b/ucloud/api/schemas.py @@ -150,7 +150,6 @@ class CreateImageSchema(BaseSchema): class CreateHostSchema(OTPSchema): def __init__(self, data): - self.parsed_specs = {} # Fields self.specs = Field("specs", dict, data.get("specs", KeyError)) self.hostname = Field( @@ -234,8 +233,6 @@ class CreateHostSchema(OTPSchema): class CreateVMSchema(OTPSchema): def __init__(self, data): - self.parsed_specs = {} - # Fields self.specs = Field("specs", dict, data.get("specs", KeyError)) self.vm_name = Field( diff --git a/ucloud/common/helpers.py b/ucloud/common/helpers.py deleted file mode 100644 index 501aa90..0000000 --- a/ucloud/common/helpers.py +++ /dev/null @@ -1,40 +0,0 @@ -import logging -import socket -import requests -import json - -from ipaddress import ip_address - -from os.path import join as join_path -from . import logger - - -# TODO: Should be removed as soon as migration -# mechanism is finalized inside ucloud -def get_ipv4_address(): - # If host is connected to internet - # Return IPv4 address of machine - # Otherwise, return 127.0.0.1 - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: - try: - s.connect(("8.8.8.8", 80)) - except socket.timeout: - address = "127.0.0.1" - except Exception as e: - logger.exception(e) - address = "127.0.0.1" - else: - address = s.getsockname()[0] - - return address - - -def get_ipv6_address(): - try: - r = requests.get("https://api6.ipify.org?format=json") - content = json.loads(r.content.decode("utf-8")) - ip = ip_address(content["ip"]).exploded - except Exception as e: - logger.exception(e) - else: - return ip diff --git a/ucloud/common/network.py b/ucloud/common/network.py index 61dbd64..adba108 100644 --- a/ucloud/common/network.py +++ b/ucloud/common/network.py @@ -70,15 +70,3 @@ def delete_network_interface(iface): except Exception: logger.exception("Interface %s Deletion failed", iface) - -def find_free_port(): - with closing( - socket.socket(socket.AF_INET, socket.SOCK_STREAM) - ) as s: - try: - s.bind(("", 0)) - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - except Exception: - return None - else: - return s.getsockname()[1] diff --git a/ucloud/host/main.py b/ucloud/host/main.py index 88dfb7c..f25a984 100755 --- a/ucloud/host/main.py +++ b/ucloud/host/main.py @@ -21,7 +21,7 @@ def update_heartbeat(hostname): while True: this_host.update_heartbeat() host_pool.put(this_host) - time.sleep(10) + time.sleep(3) def maintenance(host): diff --git a/ucloud/host/qmp/__init__.py b/ucloud/host/qmp/__init__.py deleted file mode 100755 index 40ac3a4..0000000 --- a/ucloud/host/qmp/__init__.py +++ /dev/null @@ -1,570 +0,0 @@ -# QEMU library -# -# Copyright (C) 2015-2016 Red Hat Inc. -# Copyright (C) 2012 IBM Corp. -# -# Authors: -# Fam Zheng -# -# This work is licensed under the terms of the GNU GPL, version 2. See -# the COPYING file in the top-level directory. -# -# Based on qmp.py. -# - -import errno -import logging -import os -import shutil -import socket -import subprocess -import tempfile - -from . import qmp - -LOG = logging.getLogger(__name__) - -# Mapping host architecture to any additional architectures it can -# support which often includes its 32 bit cousin. -ADDITIONAL_ARCHES = {"x86_64": "i386", "aarch64": "armhf"} - - -def kvm_available(target_arch=None): - host_arch = os.uname()[4] - if target_arch and target_arch != host_arch: - if target_arch != ADDITIONAL_ARCHES.get(host_arch): - return False - return os.access("/dev/kvm", os.R_OK | os.W_OK) - - -class QEMUMachineError(Exception): - """ - Exception called when an error in QEMUMachine happens. - """ - - -class QEMUMachineAddDeviceError(QEMUMachineError): - """ - Exception raised when a request to add a device can not be fulfilled - - The failures are caused by limitations, lack of information or conflicting - requests on the QEMUMachine methods. This exception does not represent - failures reported by the QEMU binary itself. - """ - - -class MonitorResponseError(qmp.QMPError): - """ - Represents erroneous QMP monitor reply - """ - - def __init__(self, reply): - try: - desc = reply["error"]["desc"] - except KeyError: - desc = reply - super(MonitorResponseError, self).__init__(desc) - self.reply = reply - - -class QEMUMachine(object): - """ - A QEMU VM - - Use this object as a context manager to ensure the QEMU process terminates:: - - with VM(binary) as vm: - ... - # vm is guaranteed to be shut down here - """ - - def __init__( - self, - binary, - args=None, - wrapper=None, - name=None, - test_dir="/var/tmp", - monitor_address=None, - socket_scm_helper=None, - ): - """ - Initialize a QEMUMachine - - @param binary: path to the qemu binary - @param args: list of extra arguments - @param wrapper: list of arguments used as prefix to qemu binary - @param name: prefix for socket and log file names (default: qemu-PID) - @param test_dir: where to create socket and log file - @param monitor_address: address for QMP monitor - @param socket_scm_helper: helper program, required for send_fd_scm() - @note: Qemu process is not started until launch() is used. - """ - if args is None: - args = [] - if wrapper is None: - wrapper = [] - if name is None: - name = "qemu-%d" % os.getpid() - self._name = name - self._monitor_address = monitor_address - self._vm_monitor = None - self._qemu_log_path = None - self._qemu_log_file = None - self._popen = None - self._binary = binary - self._args = list( - args - ) # Force copy args in case we modify them - self._wrapper = wrapper - self._events = [] - self._iolog = None - self._socket_scm_helper = socket_scm_helper - self._qmp = None - self._qemu_full_args = None - self._test_dir = test_dir - self._temp_dir = None - self._launched = False - self._machine = None - self._console_set = False - self._console_device_type = None - self._console_address = None - self._console_socket = None - - # just in case logging wasn't configured by the main script: - logging.basicConfig(level=logging.DEBUG) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.shutdown() - return False - - # This can be used to add an unused monitor instance. - def add_monitor_null(self): - self._args.append("-monitor") - self._args.append("null") - - def add_fd(self, fd, fdset, opaque, opts=""): - """ - Pass a file descriptor to the VM - """ - options = ["fd=%d" % fd, "set=%d" % fdset, "opaque=%s" % opaque] - if opts: - options.append(opts) - - # This did not exist before 3.4, but since then it is - # mandatory for our purpose - if hasattr(os, "set_inheritable"): - os.set_inheritable(fd, True) - - self._args.append("-add-fd") - self._args.append(",".join(options)) - return self - - # Exactly one of fd and file_path must be given. - # (If it is file_path, the helper will open that file and pass its - # own fd) - def send_fd_scm(self, fd=None, file_path=None): - # In iotest.py, the qmp should always use unix socket. - assert self._qmp.is_scm_available() - if self._socket_scm_helper is None: - raise QEMUMachineError("No path to socket_scm_helper set") - if not os.path.exists(self._socket_scm_helper): - raise QEMUMachineError( - "%s does not exist" % self._socket_scm_helper - ) - - # This did not exist before 3.4, but since then it is - # mandatory for our purpose - if hasattr(os, "set_inheritable"): - os.set_inheritable(self._qmp.get_sock_fd(), True) - if fd is not None: - os.set_inheritable(fd, True) - - fd_param = [ - "%s" % self._socket_scm_helper, - "%d" % self._qmp.get_sock_fd(), - ] - - if file_path is not None: - assert fd is None - fd_param.append(file_path) - else: - assert fd is not None - fd_param.append(str(fd)) - - devnull = open(os.path.devnull, "rb") - proc = subprocess.Popen( - fd_param, - stdin=devnull, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - close_fds=False, - ) - output = proc.communicate()[0] - if output: - LOG.debug(output) - - return proc.returncode - - @staticmethod - def _remove_if_exists(path): - """ - Remove file object at path if it exists - """ - try: - os.remove(path) - except OSError as exception: - if exception.errno == errno.ENOENT: - return - raise - - def is_running(self): - return self._popen is not None and self._popen.poll() is None - - def exitcode(self): - if self._popen is None: - return None - return self._popen.poll() - - def get_pid(self): - if not self.is_running(): - return None - return self._popen.pid - - def _load_io_log(self): - if self._qemu_log_path is not None: - with open(self._qemu_log_path, "r") as iolog: - self._iolog = iolog.read() - - def _base_args(self): - if isinstance(self._monitor_address, tuple): - moncdev = "socket,id=mon,host=%s,port=%s" % ( - self._monitor_address[0], - self._monitor_address[1], - ) - else: - moncdev = "socket,id=mon,path=%s" % self._vm_monitor - args = ["-chardev", moncdev, "-mon", "chardev=mon,mode=control"] - if self._machine is not None: - args.extend(["-machine", self._machine]) - if self._console_set: - self._console_address = os.path.join( - self._temp_dir, self._name + "-console.sock" - ) - chardev = ( - "socket,id=console,path=%s,server,nowait" - % self._console_address - ) - args.extend(["-chardev", chardev]) - if self._console_device_type is None: - args.extend(["-serial", "chardev:console"]) - else: - device = ( - "%s,chardev=console" % self._console_device_type - ) - args.extend(["-device", device]) - return args - - def _pre_launch(self): - self._temp_dir = tempfile.mkdtemp(dir=self._test_dir) - if self._monitor_address is not None: - self._vm_monitor = self._monitor_address - else: - self._vm_monitor = os.path.join( - self._temp_dir, self._name + "-monitor.sock" - ) - self._qemu_log_path = os.path.join( - self._temp_dir, self._name + ".log" - ) - self._qemu_log_file = open(self._qemu_log_path, "wb") - - self._qmp = qmp.QEMUMonitorProtocol( - self._vm_monitor, server=True - ) - - def _post_launch(self): - self._qmp.accept() - - def _post_shutdown(self): - if self._qemu_log_file is not None: - self._qemu_log_file.close() - self._qemu_log_file = None - - self._qemu_log_path = None - - if self._console_socket is not None: - self._console_socket.close() - self._console_socket = None - - if self._temp_dir is not None: - shutil.rmtree(self._temp_dir) - self._temp_dir = None - - def launch(self): - """ - Launch the VM and make sure we cleanup and expose the - command line/output in case of exception - """ - - if self._launched: - raise QEMUMachineError("VM already launched") - - self._iolog = None - self._qemu_full_args = None - try: - self._launch() - self._launched = True - except: - self.shutdown() - - LOG.debug("Error launching VM") - if self._qemu_full_args: - LOG.debug("Command: %r", " ".join(self._qemu_full_args)) - if self._iolog: - LOG.debug("Output: %r", self._iolog) - raise Exception(self._iolog) - raise - - def _launch(self): - """ - Launch the VM and establish a QMP connection - """ - devnull = open(os.path.devnull, "rb") - self._pre_launch() - self._qemu_full_args = ( - self._wrapper - + [self._binary] - + self._base_args() - + self._args - ) - LOG.debug( - "VM launch command: %r", " ".join(self._qemu_full_args) - ) - self._popen = subprocess.Popen( - self._qemu_full_args, - stdin=devnull, - stdout=self._qemu_log_file, - stderr=subprocess.STDOUT, - shell=False, - close_fds=False, - ) - self._post_launch() - - def wait(self): - """ - Wait for the VM to power off - """ - self._popen.wait() - self._qmp.close() - self._load_io_log() - self._post_shutdown() - - def shutdown(self): - """ - Terminate the VM and clean up - """ - if self.is_running(): - try: - self._qmp.cmd("quit") - self._qmp.close() - except: - self._popen.kill() - self._popen.wait() - - self._load_io_log() - self._post_shutdown() - - exitcode = self.exitcode() - if exitcode is not None and exitcode < 0: - msg = "qemu received signal %i: %s" - if self._qemu_full_args: - command = " ".join(self._qemu_full_args) - else: - command = "" - LOG.warn(msg, -exitcode, command) - - self._launched = False - - def qmp(self, cmd, conv_keys=True, **args): - """ - Invoke a QMP command and return the response dict - """ - qmp_args = dict() - for key, value in args.items(): - if conv_keys: - qmp_args[key.replace("_", "-")] = value - else: - qmp_args[key] = value - - return self._qmp.cmd(cmd, args=qmp_args) - - def command(self, cmd, conv_keys=True, **args): - """ - Invoke a QMP command. - On success return the response dict. - On failure raise an exception. - """ - reply = self.qmp(cmd, conv_keys, **args) - if reply is None: - raise qmp.QMPError("Monitor is closed") - if "error" in reply: - raise MonitorResponseError(reply) - return reply["return"] - - def get_qmp_event(self, wait=False): - """ - Poll for one queued QMP events and return it - """ - if len(self._events) > 0: - return self._events.pop(0) - return self._qmp.pull_event(wait=wait) - - def get_qmp_events(self, wait=False): - """ - Poll for queued QMP events and return a list of dicts - """ - events = self._qmp.get_events(wait=wait) - events.extend(self._events) - del self._events[:] - self._qmp.clear_events() - return events - - @staticmethod - def event_match(event, match=None): - """ - Check if an event matches optional match criteria. - - The match criteria takes the form of a matching subdict. The event is - checked to be a superset of the subdict, recursively, with matching - values whenever the subdict values are not None. - - This has a limitation that you cannot explicitly check for None values. - - Examples, with the subdict queries on the left: - - None matches any object. - - {"foo": None} matches {"foo": {"bar": 1}} - - {"foo": None} matches {"foo": 5} - - {"foo": {"abc": None}} does not match {"foo": {"bar": 1}} - - {"foo": {"rab": 2}} matches {"foo": {"bar": 1, "rab": 2}} - """ - if match is None: - return True - - try: - for key in match: - if key in event: - if not QEMUMachine.event_match( - event[key], match[key] - ): - return False - else: - return False - return True - except TypeError: - # either match or event wasn't iterable (not a dict) - return match == event - - def event_wait(self, name, timeout=60.0, match=None): - """ - event_wait waits for and returns a named event from QMP with a timeout. - - name: The event to wait for. - timeout: QEMUMonitorProtocol.pull_event timeout parameter. - match: Optional match criteria. See event_match for details. - """ - return self.events_wait([(name, match)], timeout) - - def events_wait(self, events, timeout=60.0): - """ - events_wait waits for and returns a named event from QMP with a timeout. - - events: a sequence of (name, match_criteria) tuples. - The match criteria are optional and may be None. - See event_match for details. - timeout: QEMUMonitorProtocol.pull_event timeout parameter. - """ - - def _match(event): - for name, match in events: - if event["event"] == name and self.event_match( - event, match - ): - return True - return False - - # Search cached events - for event in self._events: - if _match(event): - self._events.remove(event) - return event - - # Poll for new events - while True: - event = self._qmp.pull_event(wait=timeout) - if _match(event): - return event - self._events.append(event) - - return None - - def get_log(self): - """ - After self.shutdown or failed qemu execution, this returns the output - of the qemu process. - """ - return self._iolog - - def add_args(self, *args): - """ - Adds to the list of extra arguments to be given to the QEMU binary - """ - self._args.extend(args) - - def set_machine(self, machine_type): - """ - Sets the machine type - - If set, the machine type will be added to the base arguments - of the resulting QEMU command line. - """ - self._machine = machine_type - - def set_console(self, device_type=None): - """ - Sets the device type for a console device - - If set, the console device and a backing character device will - be added to the base arguments of the resulting QEMU command - line. - - This is a convenience method that will either use the provided - device type, or default to a "-serial chardev:console" command - line argument. - - The actual setting of command line arguments will be be done at - machine launch time, as it depends on the temporary directory - to be created. - - @param device_type: the device type, such as "isa-serial". If - None is given (the default value) a "-serial - chardev:console" command line argument will - be used instead, resorting to the machine's - default device type. - """ - self._console_set = True - self._console_device_type = device_type - - @property - def console_socket(self): - """ - Returns a socket connected to the console - """ - if self._console_socket is None: - self._console_socket = socket.socket( - socket.AF_UNIX, socket.SOCK_STREAM - ) - self._console_socket.connect(self._console_address) - return self._console_socket diff --git a/ucloud/host/qmp/qmp.py b/ucloud/host/qmp/qmp.py deleted file mode 100755 index ad187eb..0000000 --- a/ucloud/host/qmp/qmp.py +++ /dev/null @@ -1,257 +0,0 @@ -# QEMU Monitor Protocol Python class -# -# Copyright (C) 2009, 2010 Red Hat Inc. -# -# Authors: -# Luiz Capitulino -# -# This work is licensed under the terms of the GNU GPL, version 2. See -# the COPYING file in the top-level directory. - -import errno -import json -import logging -import socket - - -class QMPError(Exception): - pass - - -class QMPConnectError(QMPError): - pass - - -class QMPCapabilitiesError(QMPError): - pass - - -class QMPTimeoutError(QMPError): - pass - - -class QEMUMonitorProtocol(object): - #: Logger object for debugging messages - logger = logging.getLogger("QMP") - #: Socket's error class - error = socket.error - #: Socket's timeout - timeout = socket.timeout - - def __init__(self, address, server=False): - """ - Create a QEMUMonitorProtocol class. - - @param address: QEMU address, can be either a unix socket path (string) - or a tuple in the form ( address, port ) for a TCP - connection - @param server: server mode listens on the socket (bool) - @raise socket.error on socket connection errors - @note No connection is established, this is done by the connect() or - accept() methods - """ - self.__events = [] - self.__address = address - self.__sock = self.__get_sock() - self.__sockfile = None - if server: - self.__sock.setsockopt( - socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 - ) - self.__sock.bind(self.__address) - self.__sock.listen(1) - - def __get_sock(self): - if isinstance(self.__address, tuple): - family = socket.AF_INET - else: - family = socket.AF_UNIX - return socket.socket(family, socket.SOCK_STREAM) - - def __negotiate_capabilities(self): - greeting = self.__json_read() - if greeting is None or "QMP" not in greeting: - raise QMPConnectError - # Greeting seems ok, negotiate capabilities - resp = self.cmd("qmp_capabilities") - if "return" in resp: - return greeting - raise QMPCapabilitiesError - - def __json_read(self, only_event=False): - while True: - data = self.__sockfile.readline() - if not data: - return - resp = json.loads(data) - if "event" in resp: - self.logger.debug("<<< %s", resp) - self.__events.append(resp) - if not only_event: - continue - return resp - - def __get_events(self, wait=False): - """ - Check for new events in the stream and cache them in __events. - - @param wait (bool): block until an event is available. - @param wait (float): If wait is a float, treat it as a timeout value. - - @raise QMPTimeoutError: If a timeout float is provided and the timeout - period elapses. - @raise QMPConnectError: If wait is True but no events could be - retrieved or if some other error occurred. - """ - - # Check for new events regardless and pull them into the cache: - self.__sock.setblocking(0) - try: - self.__json_read() - except socket.error as err: - if err[0] == errno.EAGAIN: - # No data available - pass - self.__sock.setblocking(1) - - # Wait for new events, if needed. - # if wait is 0.0, this means "no wait" and is also implicitly false. - if not self.__events and wait: - if isinstance(wait, float): - self.__sock.settimeout(wait) - try: - ret = self.__json_read(only_event=True) - except socket.timeout: - raise QMPTimeoutError("Timeout waiting for event") - except: - raise QMPConnectError("Error while reading from socket") - if ret is None: - raise QMPConnectError("Error while reading from socket") - self.__sock.settimeout(None) - - def connect(self, negotiate=True): - """ - Connect to the QMP Monitor and perform capabilities negotiation. - - @return QMP greeting dict - @raise socket.error on socket connection errors - @raise QMPConnectError if the greeting is not received - @raise QMPCapabilitiesError if fails to negotiate capabilities - """ - self.__sock.connect(self.__address) - self.__sockfile = self.__sock.makefile() - if negotiate: - return self.__negotiate_capabilities() - - def accept(self): - """ - Await connection from QMP Monitor and perform capabilities negotiation. - - @return QMP greeting dict - @raise socket.error on socket connection errors - @raise QMPConnectError if the greeting is not received - @raise QMPCapabilitiesError if fails to negotiate capabilities - """ - self.__sock.settimeout(15) - self.__sock, _ = self.__sock.accept() - self.__sockfile = self.__sock.makefile() - return self.__negotiate_capabilities() - - def cmd_obj(self, qmp_cmd): - """ - Send a QMP command to the QMP Monitor. - - @param qmp_cmd: QMP command to be sent as a Python dict - @return QMP response as a Python dict or None if the connection has - been closed - """ - self.logger.debug(">>> %s", qmp_cmd) - try: - self.__sock.sendall(json.dumps(qmp_cmd).encode("utf-8")) - except socket.error as err: - if err[0] == errno.EPIPE: - return - raise socket.error(err) - resp = self.__json_read() - self.logger.debug("<<< %s", resp) - return resp - - def cmd(self, name, args=None, cmd_id=None): - """ - Build a QMP command and send it to the QMP Monitor. - - @param name: command name (string) - @param args: command arguments (dict) - @param cmd_id: command id (dict, list, string or int) - """ - qmp_cmd = {"execute": name} - if args: - qmp_cmd["arguments"] = args - if cmd_id: - qmp_cmd["id"] = cmd_id - return self.cmd_obj(qmp_cmd) - - def command(self, cmd, **kwds): - """ - Build and send a QMP command to the monitor, report errors if any - """ - ret = self.cmd(cmd, kwds) - if "error" in ret: - raise Exception(ret["error"]["desc"]) - return ret["return"] - - def pull_event(self, wait=False): - """ - Pulls a single event. - - @param wait (bool): block until an event is available. - @param wait (float): If wait is a float, treat it as a timeout value. - - @raise QMPTimeoutError: If a timeout float is provided and the timeout - period elapses. - @raise QMPConnectError: If wait is True but no events could be - retrieved or if some other error occurred. - - @return The first available QMP event, or None. - """ - self.__get_events(wait) - - if self.__events: - return self.__events.pop(0) - return None - - def get_events(self, wait=False): - """ - Get a list of available QMP events. - - @param wait (bool): block until an event is available. - @param wait (float): If wait is a float, treat it as a timeout value. - - @raise QMPTimeoutError: If a timeout float is provided and the timeout - period elapses. - @raise QMPConnectError: If wait is True but no events could be - retrieved or if some other error occurred. - - @return The list of available QMP events. - """ - self.__get_events(wait) - return self.__events - - def clear_events(self): - """ - Clear current list of pending events. - """ - self.__events = [] - - def close(self): - self.__sock.close() - self.__sockfile.close() - - def settimeout(self, timeout): - self.__sock.settimeout(timeout) - - def get_sock_fd(self): - return self.__sock.fileno() - - def is_scm_available(self): - return self.__sock.family == socket.AF_UNIX From 9963e9c62d0028495e2ba03162c56b87ea437acb Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 30 Dec 2019 15:18:25 +0500 Subject: [PATCH 092/284] Slow down heartbeat update --- ucloud/host/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ucloud/host/main.py b/ucloud/host/main.py index f25a984..88dfb7c 100755 --- a/ucloud/host/main.py +++ b/ucloud/host/main.py @@ -21,7 +21,7 @@ def update_heartbeat(hostname): while True: this_host.update_heartbeat() host_pool.put(this_host) - time.sleep(3) + time.sleep(10) def maintenance(host): From d2d6c6bf5cb39621ae4f843501a5c01a020e0bd6 Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 30 Dec 2019 15:30:26 +0500 Subject: [PATCH 093/284] Use UTC time for heartbeat mechanism --- ucloud/common/host.py | 4 ++-- ucloud/scheduler/main.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/ucloud/common/host.py b/ucloud/common/host.py index 191a2c0..01e2091 100644 --- a/ucloud/common/host.py +++ b/ucloud/common/host.py @@ -26,13 +26,13 @@ class HostEntry(SpecificEtcdEntryBase): def update_heartbeat(self): self.status = HostStatus.alive - self.last_heartbeat = time.strftime("%Y-%m-%d %H:%M:%S") + self.last_heartbeat = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") def is_alive(self): last_heartbeat = datetime.strptime( self.last_heartbeat, "%Y-%m-%d %H:%M:%S" ) - delta = datetime.now() - last_heartbeat + delta = datetime.utcnow() - last_heartbeat if delta.total_seconds() > 60: return False return True diff --git a/ucloud/scheduler/main.py b/ucloud/scheduler/main.py index 7ee75e0..051b338 100755 --- a/ucloud/scheduler/main.py +++ b/ucloud/scheduler/main.py @@ -8,7 +8,6 @@ from ucloud.common.request import RequestEntry, RequestType from ucloud.shared import shared from ucloud.settings import settings from .helper import ( - get_suitable_host, dead_host_mitigation, dead_host_detection, assign_host, From d13a4bcc3778b5032f117857587f2b7857c56096 Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 30 Dec 2019 20:05:12 +0500 Subject: [PATCH 094/284] Remove pending vm handling mechanism from scheduler + fixed issue that update VM's hostname even on migration failure --- scripts/ucloud | 2 +- ucloud/api/main.py | 4 +- ucloud/host/main.py | 93 +++++++++++------------------------ ucloud/host/virtualmachine.py | 14 +++--- ucloud/scheduler/main.py | 21 +------- 5 files changed, 40 insertions(+), 94 deletions(-) diff --git a/scripts/ucloud b/scripts/ucloud index 9d05118..05e47a5 100755 --- a/scripts/ucloud +++ b/scripts/ucloud @@ -24,7 +24,7 @@ sys.excepthook = exception_hook if __name__ == '__main__': # Setting up root logger logger = logging.getLogger() - logger.setLevel(logging.INFO) + logger.setLevel(logging.DEBUG) syslog_handler = SysLogHandler(address='/dev/log') syslog_handler.setLevel(logging.DEBUG) diff --git a/ucloud/api/main.py b/ucloud/api/main.py index d4cdbe9..85133df 100644 --- a/ucloud/api/main.py +++ b/ucloud/api/main.py @@ -567,7 +567,7 @@ def main(): settings["etcd"]["image_store_prefix"], value_in_json=True ) ) - if len(image_stores) == 0: + if not image_stores: data = { "is_public": True, "type": "ceph", @@ -583,7 +583,7 @@ def main(): json.dumps(data), ) - app.run(host="::", debug=True) + app.run(host="::", debug=False) if __name__ == "__main__": diff --git a/ucloud/host/main.py b/ucloud/host/main.py index 88dfb7c..ed734b5 100755 --- a/ucloud/host/main.py +++ b/ucloud/host/main.py @@ -28,10 +28,8 @@ def maintenance(host): vmm = VMM() running_vms = vmm.discover() for vm_uuid in running_vms: - if ( - vmm.is_running(vm_uuid) - and vmm.get_status(vm_uuid) == "running" - ): + if vmm.is_running(vm_uuid) and vmm.get_status(vm_uuid) == "running": + logger.debug('VM {} is running on {}'.format(vm_uuid, host)) vm = shared.vm_pool.get( join_path(settings["etcd"]["vm_prefix"], vm_uuid) ) @@ -43,32 +41,18 @@ def maintenance(host): def main(hostname): host_pool = shared.host_pool - host = next( - filter(lambda h: h.hostname == hostname, host_pool.hosts), None - ) - assert host is not None, "No such host with name = {}".format( - hostname - ) + host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) + assert host is not None, "No such host with name = {}".format(hostname) try: - heartbeat_updating_process = mp.Process( - target=update_heartbeat, args=(hostname,) - ) + heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(hostname,)) heartbeat_updating_process.start() except Exception as e: - raise Exception( - "ucloud-host heartbeat updating mechanism is not working" - ) from e + raise Exception("ucloud-host heartbeat updating mechanism is not working") from e for events_iterator in [ - shared.etcd_client.get_prefix( - settings["etcd"]["request_prefix"], value_in_json=True - ), - shared.etcd_client.watch_prefix( - settings["etcd"]["request_prefix"], - timeout=10, - value_in_json=True, - ), + shared.etcd_client.get_prefix(settings["etcd"]["request_prefix"], value_in_json=True), + shared.etcd_client.watch_prefix(settings["etcd"]["request_prefix"], timeout=10, value_in_json=True) ]: for request_event in events_iterator: request_event = RequestEntry(request_event) @@ -76,52 +60,35 @@ def main(hostname): if request_event.type == "TIMEOUT": maintenance(host.key) - if request_event.hostname == host.key: - logger.debug("VM Request: %s", request_event) - - shared.request_pool.client.client.delete( - request_event.key - ) + elif request_event.hostname == host.key: + logger.debug("VM Request: %s on Host %s", request_event, host.hostname) + shared.request_pool.client.client.delete(request_event.key) vm_entry = shared.etcd_client.get( - join_path( - settings["etcd"]["vm_prefix"], - request_event.uuid, - ) + join_path(settings["etcd"]["vm_prefix"], request_event.uuid) ) + logger.debug("VM hostname: {}".format(vm_entry.value)) + vm = virtualmachine.VM(vm_entry) + if request_event.type == RequestType.StartVM: + vm.start() - if vm_entry: - vm = virtualmachine.VM(vm_entry) - if request_event.type == RequestType.StartVM: - vm.start() + elif request_event.type == RequestType.StopVM: + vm.stop() - elif request_event.type == RequestType.StopVM: - vm.stop() + elif request_event.type == RequestType.DeleteVM: + vm.delete() - elif request_event.type == RequestType.DeleteVM: - vm.delete() + elif request_event.type == RequestType.InitVMMigration: + vm.start(destination_host_key=host.key) - elif ( - request_event.type - == RequestType.InitVMMigration - ): - vm.start(destination_host_key=host.key) - - elif request_event.type == RequestType.TransferVM: - host = host_pool.get( - request_event.destination_host_key + elif request_event.type == RequestType.TransferVM: + destination_host = host_pool.get(request_event.destination_host_key) + if destination_host: + vm.migrate( + destination_host=destination_host.hostname, + destination_sock_path=request_event.destination_sock_path, ) - if host: - vm.migrate( - destination_host=host.hostname, - destination_sock_path=request_event.destination_sock_path, - ) - else: - logger.error( - "Host %s not found!", - request_event.destination_host_key, - ) - else: - logger.info("VM Entry missing") + else: + logger.error("Host %s not found!", request_event.destination_host_key) if __name__ == "__main__": diff --git a/ucloud/host/virtualmachine.py b/ucloud/host/virtualmachine.py index d795b3f..8f6c79e 100755 --- a/ucloud/host/virtualmachine.py +++ b/ucloud/host/virtualmachine.py @@ -38,13 +38,14 @@ class VM: else: self.uuid = vm_entry.key.split("/")[-1] self.host_key = self.vm["hostname"] + logger.debug('VM Hostname {}'.format(self.host_key)) def get_qemu_args(self): command = ( - "-name {owner}_{name}" - " -drive file={file},format=raw,if=virtio,cache=none" + "-drive file={file},format=raw,if=virtio,cache=none" " -device virtio-rng-pci" " -m {memory} -smp cores={cores},threads={threads}" + " -name {owner}_{name}" ).format( owner=self.vm["owner"], name=self.vm["name"], @@ -67,11 +68,7 @@ class VM: except Exception as err: declare_stopped(self.vm) self.vm["log"].append("Cannot Setup Network Properly") - logger.error( - "Cannot Setup Network Properly for vm %s", - self.uuid, - exc_info=err, - ) + logger.error("Cannot Setup Network Properly for vm %s", self.uuid, exc_info=err) else: self.vmm.start( uuid=self.uuid, @@ -81,6 +78,7 @@ class VM: ) status = self.vmm.get_status(self.uuid) + logger.debug('VM {} status is {}'.format(self.uuid, status)) if status == "running": self.vm["status"] = VMStatus.running self.vm["vnc_socket"] = self.vmm.get_vnc(self.uuid) @@ -99,7 +97,7 @@ class VM: else: self.stop() declare_stopped(self.vm) - + logger.debug('VM {} has hostname {}'.format(self.uuid, self.vm['hostname'])) self.sync() def stop(self): diff --git a/ucloud/scheduler/main.py b/ucloud/scheduler/main.py index 051b338..d64017a 100755 --- a/ucloud/scheduler/main.py +++ b/ucloud/scheduler/main.py @@ -17,8 +17,6 @@ from . import logger def main(): - pending_vms = [] - for request_iterator in [ shared.etcd_client.get_prefix( settings["etcd"]["request_prefix"], value_in_json=True @@ -44,24 +42,8 @@ def main(): logger.debug("Dead hosts: %s", dead_hosts) dead_host_mitigation(dead_hosts) - # If there are VMs that weren't assigned a host - # because there wasn't a host available which - # meets requirement of that VM then we would - # create a new ScheduleVM request for that VM - # on our behalf. - while pending_vms: - pending_vm_entry = pending_vms.pop() - r = RequestEntry.from_scratch( - type="ScheduleVM", - uuid=pending_vm_entry.uuid, - hostname=pending_vm_entry.hostname, - request_prefix=settings["etcd"][ - "request_prefix" - ], - ) - shared.request_pool.put(r) - elif request_entry.type == RequestType.ScheduleVM: + print(request_event.value) logger.debug( "%s, %s", request_entry.key, request_entry.value ) @@ -86,7 +68,6 @@ def main(): ) shared.vm_pool.put(vm_entry) - pending_vms.append(vm_entry) logger.info("No Resource Left. Emailing admin....") From 4b7d6d5099ea7d944a03c4cb0e61b00a3bed3db8 Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 30 Dec 2019 21:14:08 +0500 Subject: [PATCH 095/284] Bug fixed in migration code --- ucloud/vmm/__init__.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/ucloud/vmm/__init__.py b/ucloud/vmm/__init__.py index 3d3c304..d64473b 100644 --- a/ucloud/vmm/__init__.py +++ b/ucloud/vmm/__init__.py @@ -57,6 +57,7 @@ class TransferVM(Process): os.remove(self.src_sock_path) command = [ + "sudo" "ssh", "-nNT", "-L", @@ -73,9 +74,7 @@ class TransferVM(Process): else: time.sleep(2) vmm = VMM() - logger.debug( - "Executing: ssh forwarding command: %s", command - ) + logger.debug("Executing: ssh forwarding command: %s", command) vmm.execute_command( self.src_uuid, command="migrate", @@ -83,22 +82,20 @@ class TransferVM(Process): ) while p.poll() is None: - success, output = vmm.execute_command( - self.src_uuid, command="query-migrate" - ) + success, output = vmm.execute_command(self.src_uuid, command="query-migrate") if success: status = output["return"]["status"] - - if status != "active": - print("Migration Status: ", status) - if status == "completed": - vmm.stop(self.src_uuid) + logger.info('Migration Status: {}'.format(status)) + if status == "completed": + vmm.stop(self.src_uuid) + return + elif status in ['failed', 'cancelled']: return - else: - print("Migration Status: ", status) else: + logger.error("Couldn't be able to query VM {} that was in migration".format(self.src_uuid)) return - time.sleep(0.2) + + time.sleep(2) class VMM: From 27e780b359e1f12f06debae1ecf7c76620d56481 Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 30 Dec 2019 21:30:59 +0500 Subject: [PATCH 096/284] Remove unneccassary sudo from ssh forwarding command --- ucloud/vmm/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ucloud/vmm/__init__.py b/ucloud/vmm/__init__.py index d64473b..4a7fc2f 100644 --- a/ucloud/vmm/__init__.py +++ b/ucloud/vmm/__init__.py @@ -57,7 +57,6 @@ class TransferVM(Process): os.remove(self.src_sock_path) command = [ - "sudo" "ssh", "-nNT", "-L", From 6a40a7f12fe8f900b05c8dab82fb9c71d533e3bc Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 30 Dec 2019 23:22:00 +0500 Subject: [PATCH 097/284] sshtunnel, sphinx, sphinx-rtd-theme, werkzeug removed from dependencies --- setup.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/setup.py b/setup.py index 956656b..3cf10a0 100644 --- a/setup.py +++ b/setup.py @@ -37,13 +37,9 @@ setup( "flask-restful", "bitmath", "pyotp", - "sshtunnel", - "sphinx", "pynetbox", "colorama", - "sphinx-rtd-theme", "etcd3 @ https://github.com/kragniz/python-etcd3/tarball/master#egg=etcd3", - "werkzeug", "marshmallow", ], scripts=["scripts/ucloud"], From 70c8da544e464c5411a03f8a0f0e8390cdc15c86 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Mon, 30 Dec 2019 20:06:15 +0100 Subject: [PATCH 098/284] [refactor] rename scripts to uncloud --- bin/gen-version | 2 +- bin/{ucloud => uncloud} | 2 +- bin/{ucloud-run-reinstall => uncloud-run-reinstall} | 4 ++-- scripts/{ucloud => uncloud} | 0 4 files changed, 4 insertions(+), 4 deletions(-) rename bin/{ucloud => uncloud} (97%) rename bin/{ucloud-run-reinstall => uncloud-run-reinstall} (95%) rename scripts/{ucloud => uncloud} (100%) diff --git a/bin/gen-version b/bin/gen-version index 8f622b8..a2e2882 100755 --- a/bin/gen-version +++ b/bin/gen-version @@ -26,4 +26,4 @@ dir=${0%/*} # Ensure version is present - the bundled/shipped version contains a static version, # the git version contains a dynamic version -printf "VERSION = \"%s\"\n" "$(git describe)" > ${dir}/../ucloud/version.py +printf "VERSION = \"%s\"\n" "$(git describe)" > ${dir}/../uncloud/version.py diff --git a/bin/ucloud b/bin/uncloud similarity index 97% rename from bin/ucloud rename to bin/uncloud index ba337fd..1c572d5 100755 --- a/bin/ucloud +++ b/bin/uncloud @@ -30,4 +30,4 @@ ${dir}/gen-version libdir=$(cd "${dir}/../" && pwd -P) export PYTHONPATH="${libdir}" -"$dir/../scripts/ucloud" "$@" +"$dir/../scripts/uncloud" "$@" diff --git a/bin/ucloud-run-reinstall b/bin/uncloud-run-reinstall similarity index 95% rename from bin/ucloud-run-reinstall rename to bin/uncloud-run-reinstall index b189bbc..18e95c0 100755 --- a/bin/ucloud-run-reinstall +++ b/bin/uncloud-run-reinstall @@ -24,6 +24,6 @@ dir=${0%/*} ${dir}/gen-version; -pip uninstall -y ucloud +pip uninstall -y uncloud python setup.py install -${dir}/ucloud "$@" +${dir}/uncloud "$@" diff --git a/scripts/ucloud b/scripts/uncloud similarity index 100% rename from scripts/ucloud rename to scripts/uncloud From 7b6c02b3ab2ade5a7b725e4e5e47e742f592508f Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 31 Dec 2019 11:29:08 +0100 Subject: [PATCH 099/284] find ucloud -name \*.py -exec sed -i "s/ucloud/uncloud/g" {} \; --- {ucloud => uncloud}/__init__.py | 0 {ucloud => uncloud}/api/README.md | 0 {ucloud => uncloud}/api/__init__.py | 0 {ucloud => uncloud}/api/common_fields.py | 0 {ucloud => uncloud}/api/create_image_store.py | 0 {ucloud => uncloud}/api/helper.py | 0 {ucloud => uncloud}/api/main.py | 0 {ucloud => uncloud}/api/schemas.py | 0 {ucloud => uncloud}/common/__init__.py | 0 {ucloud => uncloud}/common/classes.py | 0 {ucloud => uncloud}/common/counters.py | 0 {ucloud => uncloud}/common/etcd_wrapper.py | 0 {ucloud => uncloud}/common/host.py | 0 {ucloud => uncloud}/common/logging.py | 0 {ucloud => uncloud}/common/network.py | 0 {ucloud => uncloud}/common/request.py | 0 {ucloud => uncloud}/common/schemas.py | 0 {ucloud => uncloud}/common/storage_handlers.py | 0 {ucloud => uncloud}/common/vm.py | 0 {ucloud => uncloud}/configure/__init__.py | 0 {ucloud => uncloud}/configure/main.py | 0 {ucloud => uncloud}/docs/Makefile | 0 {ucloud => uncloud}/docs/__init__.py | 0 {ucloud => uncloud}/docs/source/__init__.py | 0 {ucloud => uncloud}/docs/source/admin-guide | 0 {ucloud => uncloud}/docs/source/conf.py | 0 {ucloud => uncloud}/docs/source/diagram-code/ucloud | 0 {ucloud => uncloud}/docs/source/hacking.rst | 0 {ucloud => uncloud}/docs/source/images/ucloud.svg | 0 {ucloud => uncloud}/docs/source/index.rst | 0 {ucloud => uncloud}/docs/source/introduction.rst | 0 {ucloud => uncloud}/docs/source/misc/todo.rst | 0 {ucloud => uncloud}/docs/source/setup-install.rst | 0 {ucloud => uncloud}/docs/source/theory/summary.rst | 0 {ucloud => uncloud}/docs/source/troubleshooting.rst | 0 {ucloud => uncloud}/docs/source/user-guide.rst | 0 .../source/user-guide/how-to-create-an-os-image-for-ucloud.rst | 0 {ucloud => uncloud}/filescanner/__init__.py | 0 {ucloud => uncloud}/filescanner/main.py | 0 {ucloud => uncloud}/hack/README.org | 0 {ucloud => uncloud}/hack/conf.d/ucloud-host | 0 {ucloud => uncloud}/hack/nftables.conf | 0 {ucloud => uncloud}/hack/rc-scripts/ucloud-api | 0 {ucloud => uncloud}/hack/rc-scripts/ucloud-host | 0 {ucloud => uncloud}/hack/rc-scripts/ucloud-metadata | 0 {ucloud => uncloud}/hack/rc-scripts/ucloud-scheduler | 0 {ucloud => uncloud}/host/__init__.py | 0 {ucloud => uncloud}/host/main.py | 0 {ucloud => uncloud}/host/virtualmachine.py | 0 {ucloud => uncloud}/imagescanner/__init__.py | 0 {ucloud => uncloud}/imagescanner/main.py | 0 {ucloud => uncloud}/metadata/__init__.py | 0 {ucloud => uncloud}/metadata/main.py | 0 {ucloud => uncloud}/network/README | 0 {ucloud => uncloud}/network/__init__.py | 0 {ucloud => uncloud}/network/create-bridge.sh | 0 {ucloud => uncloud}/network/create-tap.sh | 0 {ucloud => uncloud}/network/create-vxlan.sh | 0 {ucloud => uncloud}/network/radvd-template.conf | 0 {ucloud => uncloud}/scheduler/__init__.py | 0 {ucloud => uncloud}/scheduler/helper.py | 0 {ucloud => uncloud}/scheduler/main.py | 0 {ucloud => uncloud}/scheduler/tests/__init__.py | 0 {ucloud => uncloud}/scheduler/tests/test_basics.py | 0 {ucloud => uncloud}/scheduler/tests/test_dead_host_mechanism.py | 0 {ucloud => uncloud}/settings/__init__.py | 0 {ucloud => uncloud}/shared/__init__.py | 0 {ucloud => uncloud}/vmm/__init__.py | 0 68 files changed, 0 insertions(+), 0 deletions(-) rename {ucloud => uncloud}/__init__.py (100%) rename {ucloud => uncloud}/api/README.md (100%) rename {ucloud => uncloud}/api/__init__.py (100%) rename {ucloud => uncloud}/api/common_fields.py (100%) rename {ucloud => uncloud}/api/create_image_store.py (100%) rename {ucloud => uncloud}/api/helper.py (100%) rename {ucloud => uncloud}/api/main.py (100%) rename {ucloud => uncloud}/api/schemas.py (100%) rename {ucloud => uncloud}/common/__init__.py (100%) rename {ucloud => uncloud}/common/classes.py (100%) rename {ucloud => uncloud}/common/counters.py (100%) rename {ucloud => uncloud}/common/etcd_wrapper.py (100%) rename {ucloud => uncloud}/common/host.py (100%) rename {ucloud => uncloud}/common/logging.py (100%) rename {ucloud => uncloud}/common/network.py (100%) rename {ucloud => uncloud}/common/request.py (100%) rename {ucloud => uncloud}/common/schemas.py (100%) rename {ucloud => uncloud}/common/storage_handlers.py (100%) rename {ucloud => uncloud}/common/vm.py (100%) rename {ucloud => uncloud}/configure/__init__.py (100%) rename {ucloud => uncloud}/configure/main.py (100%) rename {ucloud => uncloud}/docs/Makefile (100%) rename {ucloud => uncloud}/docs/__init__.py (100%) rename {ucloud => uncloud}/docs/source/__init__.py (100%) rename {ucloud => uncloud}/docs/source/admin-guide (100%) rename {ucloud => uncloud}/docs/source/conf.py (100%) rename {ucloud => uncloud}/docs/source/diagram-code/ucloud (100%) rename {ucloud => uncloud}/docs/source/hacking.rst (100%) rename {ucloud => uncloud}/docs/source/images/ucloud.svg (100%) rename {ucloud => uncloud}/docs/source/index.rst (100%) rename {ucloud => uncloud}/docs/source/introduction.rst (100%) rename {ucloud => uncloud}/docs/source/misc/todo.rst (100%) rename {ucloud => uncloud}/docs/source/setup-install.rst (100%) rename {ucloud => uncloud}/docs/source/theory/summary.rst (100%) rename {ucloud => uncloud}/docs/source/troubleshooting.rst (100%) rename {ucloud => uncloud}/docs/source/user-guide.rst (100%) rename {ucloud => uncloud}/docs/source/user-guide/how-to-create-an-os-image-for-ucloud.rst (100%) rename {ucloud => uncloud}/filescanner/__init__.py (100%) rename {ucloud => uncloud}/filescanner/main.py (100%) rename {ucloud => uncloud}/hack/README.org (100%) rename {ucloud => uncloud}/hack/conf.d/ucloud-host (100%) rename {ucloud => uncloud}/hack/nftables.conf (100%) rename {ucloud => uncloud}/hack/rc-scripts/ucloud-api (100%) rename {ucloud => uncloud}/hack/rc-scripts/ucloud-host (100%) rename {ucloud => uncloud}/hack/rc-scripts/ucloud-metadata (100%) rename {ucloud => uncloud}/hack/rc-scripts/ucloud-scheduler (100%) rename {ucloud => uncloud}/host/__init__.py (100%) rename {ucloud => uncloud}/host/main.py (100%) rename {ucloud => uncloud}/host/virtualmachine.py (100%) rename {ucloud => uncloud}/imagescanner/__init__.py (100%) rename {ucloud => uncloud}/imagescanner/main.py (100%) rename {ucloud => uncloud}/metadata/__init__.py (100%) rename {ucloud => uncloud}/metadata/main.py (100%) rename {ucloud => uncloud}/network/README (100%) rename {ucloud => uncloud}/network/__init__.py (100%) rename {ucloud => uncloud}/network/create-bridge.sh (100%) rename {ucloud => uncloud}/network/create-tap.sh (100%) rename {ucloud => uncloud}/network/create-vxlan.sh (100%) rename {ucloud => uncloud}/network/radvd-template.conf (100%) rename {ucloud => uncloud}/scheduler/__init__.py (100%) rename {ucloud => uncloud}/scheduler/helper.py (100%) rename {ucloud => uncloud}/scheduler/main.py (100%) rename {ucloud => uncloud}/scheduler/tests/__init__.py (100%) rename {ucloud => uncloud}/scheduler/tests/test_basics.py (100%) rename {ucloud => uncloud}/scheduler/tests/test_dead_host_mechanism.py (100%) rename {ucloud => uncloud}/settings/__init__.py (100%) rename {ucloud => uncloud}/shared/__init__.py (100%) rename {ucloud => uncloud}/vmm/__init__.py (100%) diff --git a/ucloud/__init__.py b/uncloud/__init__.py similarity index 100% rename from ucloud/__init__.py rename to uncloud/__init__.py diff --git a/ucloud/api/README.md b/uncloud/api/README.md similarity index 100% rename from ucloud/api/README.md rename to uncloud/api/README.md diff --git a/ucloud/api/__init__.py b/uncloud/api/__init__.py similarity index 100% rename from ucloud/api/__init__.py rename to uncloud/api/__init__.py diff --git a/ucloud/api/common_fields.py b/uncloud/api/common_fields.py similarity index 100% rename from ucloud/api/common_fields.py rename to uncloud/api/common_fields.py diff --git a/ucloud/api/create_image_store.py b/uncloud/api/create_image_store.py similarity index 100% rename from ucloud/api/create_image_store.py rename to uncloud/api/create_image_store.py diff --git a/ucloud/api/helper.py b/uncloud/api/helper.py similarity index 100% rename from ucloud/api/helper.py rename to uncloud/api/helper.py diff --git a/ucloud/api/main.py b/uncloud/api/main.py similarity index 100% rename from ucloud/api/main.py rename to uncloud/api/main.py diff --git a/ucloud/api/schemas.py b/uncloud/api/schemas.py similarity index 100% rename from ucloud/api/schemas.py rename to uncloud/api/schemas.py diff --git a/ucloud/common/__init__.py b/uncloud/common/__init__.py similarity index 100% rename from ucloud/common/__init__.py rename to uncloud/common/__init__.py diff --git a/ucloud/common/classes.py b/uncloud/common/classes.py similarity index 100% rename from ucloud/common/classes.py rename to uncloud/common/classes.py diff --git a/ucloud/common/counters.py b/uncloud/common/counters.py similarity index 100% rename from ucloud/common/counters.py rename to uncloud/common/counters.py diff --git a/ucloud/common/etcd_wrapper.py b/uncloud/common/etcd_wrapper.py similarity index 100% rename from ucloud/common/etcd_wrapper.py rename to uncloud/common/etcd_wrapper.py diff --git a/ucloud/common/host.py b/uncloud/common/host.py similarity index 100% rename from ucloud/common/host.py rename to uncloud/common/host.py diff --git a/ucloud/common/logging.py b/uncloud/common/logging.py similarity index 100% rename from ucloud/common/logging.py rename to uncloud/common/logging.py diff --git a/ucloud/common/network.py b/uncloud/common/network.py similarity index 100% rename from ucloud/common/network.py rename to uncloud/common/network.py diff --git a/ucloud/common/request.py b/uncloud/common/request.py similarity index 100% rename from ucloud/common/request.py rename to uncloud/common/request.py diff --git a/ucloud/common/schemas.py b/uncloud/common/schemas.py similarity index 100% rename from ucloud/common/schemas.py rename to uncloud/common/schemas.py diff --git a/ucloud/common/storage_handlers.py b/uncloud/common/storage_handlers.py similarity index 100% rename from ucloud/common/storage_handlers.py rename to uncloud/common/storage_handlers.py diff --git a/ucloud/common/vm.py b/uncloud/common/vm.py similarity index 100% rename from ucloud/common/vm.py rename to uncloud/common/vm.py diff --git a/ucloud/configure/__init__.py b/uncloud/configure/__init__.py similarity index 100% rename from ucloud/configure/__init__.py rename to uncloud/configure/__init__.py diff --git a/ucloud/configure/main.py b/uncloud/configure/main.py similarity index 100% rename from ucloud/configure/main.py rename to uncloud/configure/main.py diff --git a/ucloud/docs/Makefile b/uncloud/docs/Makefile similarity index 100% rename from ucloud/docs/Makefile rename to uncloud/docs/Makefile diff --git a/ucloud/docs/__init__.py b/uncloud/docs/__init__.py similarity index 100% rename from ucloud/docs/__init__.py rename to uncloud/docs/__init__.py diff --git a/ucloud/docs/source/__init__.py b/uncloud/docs/source/__init__.py similarity index 100% rename from ucloud/docs/source/__init__.py rename to uncloud/docs/source/__init__.py diff --git a/ucloud/docs/source/admin-guide b/uncloud/docs/source/admin-guide similarity index 100% rename from ucloud/docs/source/admin-guide rename to uncloud/docs/source/admin-guide diff --git a/ucloud/docs/source/conf.py b/uncloud/docs/source/conf.py similarity index 100% rename from ucloud/docs/source/conf.py rename to uncloud/docs/source/conf.py diff --git a/ucloud/docs/source/diagram-code/ucloud b/uncloud/docs/source/diagram-code/ucloud similarity index 100% rename from ucloud/docs/source/diagram-code/ucloud rename to uncloud/docs/source/diagram-code/ucloud diff --git a/ucloud/docs/source/hacking.rst b/uncloud/docs/source/hacking.rst similarity index 100% rename from ucloud/docs/source/hacking.rst rename to uncloud/docs/source/hacking.rst diff --git a/ucloud/docs/source/images/ucloud.svg b/uncloud/docs/source/images/ucloud.svg similarity index 100% rename from ucloud/docs/source/images/ucloud.svg rename to uncloud/docs/source/images/ucloud.svg diff --git a/ucloud/docs/source/index.rst b/uncloud/docs/source/index.rst similarity index 100% rename from ucloud/docs/source/index.rst rename to uncloud/docs/source/index.rst diff --git a/ucloud/docs/source/introduction.rst b/uncloud/docs/source/introduction.rst similarity index 100% rename from ucloud/docs/source/introduction.rst rename to uncloud/docs/source/introduction.rst diff --git a/ucloud/docs/source/misc/todo.rst b/uncloud/docs/source/misc/todo.rst similarity index 100% rename from ucloud/docs/source/misc/todo.rst rename to uncloud/docs/source/misc/todo.rst diff --git a/ucloud/docs/source/setup-install.rst b/uncloud/docs/source/setup-install.rst similarity index 100% rename from ucloud/docs/source/setup-install.rst rename to uncloud/docs/source/setup-install.rst diff --git a/ucloud/docs/source/theory/summary.rst b/uncloud/docs/source/theory/summary.rst similarity index 100% rename from ucloud/docs/source/theory/summary.rst rename to uncloud/docs/source/theory/summary.rst diff --git a/ucloud/docs/source/troubleshooting.rst b/uncloud/docs/source/troubleshooting.rst similarity index 100% rename from ucloud/docs/source/troubleshooting.rst rename to uncloud/docs/source/troubleshooting.rst diff --git a/ucloud/docs/source/user-guide.rst b/uncloud/docs/source/user-guide.rst similarity index 100% rename from ucloud/docs/source/user-guide.rst rename to uncloud/docs/source/user-guide.rst diff --git a/ucloud/docs/source/user-guide/how-to-create-an-os-image-for-ucloud.rst b/uncloud/docs/source/user-guide/how-to-create-an-os-image-for-ucloud.rst similarity index 100% rename from ucloud/docs/source/user-guide/how-to-create-an-os-image-for-ucloud.rst rename to uncloud/docs/source/user-guide/how-to-create-an-os-image-for-ucloud.rst diff --git a/ucloud/filescanner/__init__.py b/uncloud/filescanner/__init__.py similarity index 100% rename from ucloud/filescanner/__init__.py rename to uncloud/filescanner/__init__.py diff --git a/ucloud/filescanner/main.py b/uncloud/filescanner/main.py similarity index 100% rename from ucloud/filescanner/main.py rename to uncloud/filescanner/main.py diff --git a/ucloud/hack/README.org b/uncloud/hack/README.org similarity index 100% rename from ucloud/hack/README.org rename to uncloud/hack/README.org diff --git a/ucloud/hack/conf.d/ucloud-host b/uncloud/hack/conf.d/ucloud-host similarity index 100% rename from ucloud/hack/conf.d/ucloud-host rename to uncloud/hack/conf.d/ucloud-host diff --git a/ucloud/hack/nftables.conf b/uncloud/hack/nftables.conf similarity index 100% rename from ucloud/hack/nftables.conf rename to uncloud/hack/nftables.conf diff --git a/ucloud/hack/rc-scripts/ucloud-api b/uncloud/hack/rc-scripts/ucloud-api similarity index 100% rename from ucloud/hack/rc-scripts/ucloud-api rename to uncloud/hack/rc-scripts/ucloud-api diff --git a/ucloud/hack/rc-scripts/ucloud-host b/uncloud/hack/rc-scripts/ucloud-host similarity index 100% rename from ucloud/hack/rc-scripts/ucloud-host rename to uncloud/hack/rc-scripts/ucloud-host diff --git a/ucloud/hack/rc-scripts/ucloud-metadata b/uncloud/hack/rc-scripts/ucloud-metadata similarity index 100% rename from ucloud/hack/rc-scripts/ucloud-metadata rename to uncloud/hack/rc-scripts/ucloud-metadata diff --git a/ucloud/hack/rc-scripts/ucloud-scheduler b/uncloud/hack/rc-scripts/ucloud-scheduler similarity index 100% rename from ucloud/hack/rc-scripts/ucloud-scheduler rename to uncloud/hack/rc-scripts/ucloud-scheduler diff --git a/ucloud/host/__init__.py b/uncloud/host/__init__.py similarity index 100% rename from ucloud/host/__init__.py rename to uncloud/host/__init__.py diff --git a/ucloud/host/main.py b/uncloud/host/main.py similarity index 100% rename from ucloud/host/main.py rename to uncloud/host/main.py diff --git a/ucloud/host/virtualmachine.py b/uncloud/host/virtualmachine.py similarity index 100% rename from ucloud/host/virtualmachine.py rename to uncloud/host/virtualmachine.py diff --git a/ucloud/imagescanner/__init__.py b/uncloud/imagescanner/__init__.py similarity index 100% rename from ucloud/imagescanner/__init__.py rename to uncloud/imagescanner/__init__.py diff --git a/ucloud/imagescanner/main.py b/uncloud/imagescanner/main.py similarity index 100% rename from ucloud/imagescanner/main.py rename to uncloud/imagescanner/main.py diff --git a/ucloud/metadata/__init__.py b/uncloud/metadata/__init__.py similarity index 100% rename from ucloud/metadata/__init__.py rename to uncloud/metadata/__init__.py diff --git a/ucloud/metadata/main.py b/uncloud/metadata/main.py similarity index 100% rename from ucloud/metadata/main.py rename to uncloud/metadata/main.py diff --git a/ucloud/network/README b/uncloud/network/README similarity index 100% rename from ucloud/network/README rename to uncloud/network/README diff --git a/ucloud/network/__init__.py b/uncloud/network/__init__.py similarity index 100% rename from ucloud/network/__init__.py rename to uncloud/network/__init__.py diff --git a/ucloud/network/create-bridge.sh b/uncloud/network/create-bridge.sh similarity index 100% rename from ucloud/network/create-bridge.sh rename to uncloud/network/create-bridge.sh diff --git a/ucloud/network/create-tap.sh b/uncloud/network/create-tap.sh similarity index 100% rename from ucloud/network/create-tap.sh rename to uncloud/network/create-tap.sh diff --git a/ucloud/network/create-vxlan.sh b/uncloud/network/create-vxlan.sh similarity index 100% rename from ucloud/network/create-vxlan.sh rename to uncloud/network/create-vxlan.sh diff --git a/ucloud/network/radvd-template.conf b/uncloud/network/radvd-template.conf similarity index 100% rename from ucloud/network/radvd-template.conf rename to uncloud/network/radvd-template.conf diff --git a/ucloud/scheduler/__init__.py b/uncloud/scheduler/__init__.py similarity index 100% rename from ucloud/scheduler/__init__.py rename to uncloud/scheduler/__init__.py diff --git a/ucloud/scheduler/helper.py b/uncloud/scheduler/helper.py similarity index 100% rename from ucloud/scheduler/helper.py rename to uncloud/scheduler/helper.py diff --git a/ucloud/scheduler/main.py b/uncloud/scheduler/main.py similarity index 100% rename from ucloud/scheduler/main.py rename to uncloud/scheduler/main.py diff --git a/ucloud/scheduler/tests/__init__.py b/uncloud/scheduler/tests/__init__.py similarity index 100% rename from ucloud/scheduler/tests/__init__.py rename to uncloud/scheduler/tests/__init__.py diff --git a/ucloud/scheduler/tests/test_basics.py b/uncloud/scheduler/tests/test_basics.py similarity index 100% rename from ucloud/scheduler/tests/test_basics.py rename to uncloud/scheduler/tests/test_basics.py diff --git a/ucloud/scheduler/tests/test_dead_host_mechanism.py b/uncloud/scheduler/tests/test_dead_host_mechanism.py similarity index 100% rename from ucloud/scheduler/tests/test_dead_host_mechanism.py rename to uncloud/scheduler/tests/test_dead_host_mechanism.py diff --git a/ucloud/settings/__init__.py b/uncloud/settings/__init__.py similarity index 100% rename from ucloud/settings/__init__.py rename to uncloud/settings/__init__.py diff --git a/ucloud/shared/__init__.py b/uncloud/shared/__init__.py similarity index 100% rename from ucloud/shared/__init__.py rename to uncloud/shared/__init__.py diff --git a/ucloud/vmm/__init__.py b/uncloud/vmm/__init__.py similarity index 100% rename from ucloud/vmm/__init__.py rename to uncloud/vmm/__init__.py From 433a3b9817842113e5d0d5e027ab7517283b12e8 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 31 Dec 2019 11:30:02 +0100 Subject: [PATCH 100/284] refactor #2 Signed-off-by: Nico Schottelius --- setup.py | 6 +++--- uncloud/api/common_fields.py | 4 ++-- uncloud/api/create_image_store.py | 4 ++-- uncloud/api/helper.py | 4 ++-- uncloud/api/main.py | 10 +++++----- uncloud/api/schemas.py | 10 +++++----- uncloud/common/host.py | 2 +- uncloud/common/storage_handlers.py | 2 +- uncloud/configure/main.py | 4 ++-- uncloud/docs/source/conf.py | 2 +- uncloud/filescanner/main.py | 4 ++-- uncloud/host/main.py | 12 ++++++------ uncloud/host/virtualmachine.py | 16 ++++++++-------- uncloud/imagescanner/main.py | 6 +++--- uncloud/metadata/main.py | 4 ++-- uncloud/scheduler/helper.py | 10 +++++----- uncloud/scheduler/main.py | 6 +++--- uncloud/scheduler/tests/test_basics.py | 2 +- uncloud/settings/__init__.py | 10 +++++----- uncloud/shared/__init__.py | 10 +++++----- uncloud/vmm/__init__.py | 2 +- 21 files changed, 65 insertions(+), 65 deletions(-) diff --git a/setup.py b/setup.py index 3cf10a0..b204f93 100644 --- a/setup.py +++ b/setup.py @@ -17,10 +17,10 @@ except: setup( - name="ucloud", + name="uncloud", version=version, - description="All ucloud server components.", - url="https://code.ungleich.ch/ucloud/ucloud", + description="uncloud cloud management", + url="https://code.ungleich.ch/uncloud/uncloud", long_description=long_description, long_description_content_type="text/markdown", classifiers=[ diff --git a/uncloud/api/common_fields.py b/uncloud/api/common_fields.py index 93f9e06..8bcf777 100755 --- a/uncloud/api/common_fields.py +++ b/uncloud/api/common_fields.py @@ -1,7 +1,7 @@ import os -from ucloud.shared import shared -from ucloud.settings import settings +from uncloud.shared import shared +from uncloud.settings import settings class Optional: diff --git a/uncloud/api/create_image_store.py b/uncloud/api/create_image_store.py index a433ce3..73b92f1 100755 --- a/uncloud/api/create_image_store.py +++ b/uncloud/api/create_image_store.py @@ -3,8 +3,8 @@ import os from uuid import uuid4 -from ucloud.shared import shared -from ucloud.settings import settings +from uncloud.shared import shared +from uncloud.settings import settings data = { "is_public": True, diff --git a/uncloud/api/helper.py b/uncloud/api/helper.py index 6fdeb30..c806814 100755 --- a/uncloud/api/helper.py +++ b/uncloud/api/helper.py @@ -7,8 +7,8 @@ import requests from pyotp import TOTP -from ucloud.shared import shared -from ucloud.settings import settings +from uncloud.shared import shared +from uncloud.settings import settings logger = logging.getLogger(__name__) diff --git a/uncloud/api/main.py b/uncloud/api/main.py index 85133df..6ac5d44 100644 --- a/uncloud/api/main.py +++ b/uncloud/api/main.py @@ -9,11 +9,11 @@ from flask import Flask, request from flask_restful import Resource, Api from werkzeug.exceptions import HTTPException -from ucloud.common import counters -from ucloud.common.vm import VMStatus -from ucloud.common.request import RequestEntry, RequestType -from ucloud.settings import settings -from ucloud.shared import shared +from uncloud.common import counters +from uncloud.common.vm import VMStatus +from uncloud.common.request import RequestEntry, RequestType +from uncloud.settings import settings +from uncloud.shared import shared from . import schemas from .helper import generate_mac, mac2ipv6 diff --git a/uncloud/api/schemas.py b/uncloud/api/schemas.py index 91289b0..65055c4 100755 --- a/uncloud/api/schemas.py +++ b/uncloud/api/schemas.py @@ -1,6 +1,6 @@ """ This module contain classes thats validates and intercept/modify -data coming from ucloud-cli (user) +data coming from uncloud-cli (user) It was primarily developed as an alternative to argument parser of Flask_Restful which is going to be deprecated. I also tried @@ -19,10 +19,10 @@ import os import bitmath -from ucloud.common.host import HostStatus -from ucloud.common.vm import VMStatus -from ucloud.shared import shared -from ucloud.settings import settings +from uncloud.common.host import HostStatus +from uncloud.common.vm import VMStatus +from uncloud.shared import shared +from uncloud.settings import settings from . import helper, logger from .common_fields import Field, VmUUIDField from .helper import check_otp, resolve_vm_name diff --git a/uncloud/common/host.py b/uncloud/common/host.py index 01e2091..f7bb7d5 100644 --- a/uncloud/common/host.py +++ b/uncloud/common/host.py @@ -7,7 +7,7 @@ from .classes import SpecificEtcdEntryBase class HostStatus: - """Possible Statuses of ucloud host.""" + """Possible Statuses of uncloud host.""" alive = "ALIVE" dead = "DEAD" diff --git a/uncloud/common/storage_handlers.py b/uncloud/common/storage_handlers.py index b337f23..06751c4 100644 --- a/uncloud/common/storage_handlers.py +++ b/uncloud/common/storage_handlers.py @@ -7,7 +7,7 @@ from abc import ABC from . import logger from os.path import join as join_path -from ucloud.settings import settings as config +from uncloud.settings import settings as config class ImageStorageHandler(ABC): diff --git a/uncloud/configure/main.py b/uncloud/configure/main.py index 31201f6..a9b4901 100644 --- a/uncloud/configure/main.py +++ b/uncloud/configure/main.py @@ -1,7 +1,7 @@ import os -from ucloud.settings import settings -from ucloud.shared import shared +from uncloud.settings import settings +from uncloud.shared import shared def update_config(section, kwargs): diff --git a/uncloud/docs/source/conf.py b/uncloud/docs/source/conf.py index 70307f8..c8138a7 100644 --- a/uncloud/docs/source/conf.py +++ b/uncloud/docs/source/conf.py @@ -17,7 +17,7 @@ # -- Project information ----------------------------------------------------- -project = "ucloud" +project = "uncloud" copyright = "2019, ungleich" author = "ungleich" diff --git a/uncloud/filescanner/main.py b/uncloud/filescanner/main.py index b12797b..7ce8654 100755 --- a/uncloud/filescanner/main.py +++ b/uncloud/filescanner/main.py @@ -7,8 +7,8 @@ import time from uuid import uuid4 from . import logger -from ucloud.settings import settings -from ucloud.shared import shared +from uncloud.settings import settings +from uncloud.shared import shared def sha512sum(file: str): diff --git a/uncloud/host/main.py b/uncloud/host/main.py index ed734b5..80527c9 100755 --- a/uncloud/host/main.py +++ b/uncloud/host/main.py @@ -2,11 +2,11 @@ import argparse import multiprocessing as mp import time -from ucloud.common.request import RequestEntry, RequestType -from ucloud.shared import shared -from ucloud.settings import settings -from ucloud.common.vm import VMStatus -from ucloud.vmm import VMM +from uncloud.common.request import RequestEntry, RequestType +from uncloud.shared import shared +from uncloud.settings import settings +from uncloud.common.vm import VMStatus +from uncloud.vmm import VMM from os.path import join as join_path from . import virtualmachine, logger @@ -48,7 +48,7 @@ def main(hostname): heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(hostname,)) heartbeat_updating_process.start() except Exception as e: - raise Exception("ucloud-host heartbeat updating mechanism is not working") from e + raise Exception("uncloud-host heartbeat updating mechanism is not working") from e for events_iterator in [ shared.etcd_client.get_prefix(settings["etcd"]["request_prefix"], value_in_json=True), diff --git a/uncloud/host/virtualmachine.py b/uncloud/host/virtualmachine.py index 8f6c79e..0bd20bf 100755 --- a/uncloud/host/virtualmachine.py +++ b/uncloud/host/virtualmachine.py @@ -11,14 +11,14 @@ import ipaddress from string import Template from os.path import join as join_path -from ucloud.common.request import RequestEntry, RequestType -from ucloud.common.vm import VMStatus, declare_stopped -from ucloud.common.network import create_dev, delete_network_interface -from ucloud.common.schemas import VMSchema, NetworkSchema -from ucloud.host import logger -from ucloud.shared import shared -from ucloud.settings import settings -from ucloud.vmm import VMM +from uncloud.common.request import RequestEntry, RequestType +from uncloud.common.vm import VMStatus, declare_stopped +from uncloud.common.network import create_dev, delete_network_interface +from uncloud.common.schemas import VMSchema, NetworkSchema +from uncloud.host import logger +from uncloud.shared import shared +from uncloud.settings import settings +from uncloud.vmm import VMM from marshmallow import ValidationError diff --git a/uncloud/imagescanner/main.py b/uncloud/imagescanner/main.py index e1960bc..93e4dd5 100755 --- a/uncloud/imagescanner/main.py +++ b/uncloud/imagescanner/main.py @@ -3,9 +3,9 @@ import os import subprocess as sp from os.path import join as join_path -from ucloud.settings import settings -from ucloud.shared import shared -from ucloud.imagescanner import logger +from uncloud.settings import settings +from uncloud.shared import shared +from uncloud.imagescanner import logger def qemu_img_type(path): diff --git a/uncloud/metadata/main.py b/uncloud/metadata/main.py index 2974e33..da993ae 100644 --- a/uncloud/metadata/main.py +++ b/uncloud/metadata/main.py @@ -4,8 +4,8 @@ from flask import Flask, request from flask_restful import Resource, Api from werkzeug.exceptions import HTTPException -from ucloud.settings import settings -from ucloud.shared import shared +from uncloud.settings import settings +from uncloud.shared import shared app = Flask(__name__) api = Api(app) diff --git a/uncloud/scheduler/helper.py b/uncloud/scheduler/helper.py index 2fb7a22..7edf623 100755 --- a/uncloud/scheduler/helper.py +++ b/uncloud/scheduler/helper.py @@ -3,11 +3,11 @@ from functools import reduce import bitmath -from ucloud.common.host import HostStatus -from ucloud.common.request import RequestEntry, RequestType -from ucloud.common.vm import VMStatus -from ucloud.shared import shared -from ucloud.settings import settings +from uncloud.common.host import HostStatus +from uncloud.common.request import RequestEntry, RequestType +from uncloud.common.vm import VMStatus +from uncloud.shared import shared +from uncloud.settings import settings def accumulated_specs(vms_specs): diff --git a/uncloud/scheduler/main.py b/uncloud/scheduler/main.py index d64017a..5a4014f 100755 --- a/uncloud/scheduler/main.py +++ b/uncloud/scheduler/main.py @@ -4,9 +4,9 @@ # 2. Introduce a status endpoint of the scheduler - # maybe expose a prometheus compatible output -from ucloud.common.request import RequestEntry, RequestType -from ucloud.shared import shared -from ucloud.settings import settings +from uncloud.common.request import RequestEntry, RequestType +from uncloud.shared import shared +from uncloud.settings import settings from .helper import ( dead_host_mitigation, dead_host_detection, diff --git a/uncloud/scheduler/tests/test_basics.py b/uncloud/scheduler/tests/test_basics.py index 68bd8ec..defeb23 100755 --- a/uncloud/scheduler/tests/test_basics.py +++ b/uncloud/scheduler/tests/test_basics.py @@ -15,7 +15,7 @@ from main import ( main, ) -from ucloud.config import etcd_client +from uncloud.config import etcd_client class TestFunctions(unittest.TestCase): diff --git a/uncloud/settings/__init__.py b/uncloud/settings/__init__.py index 906e857..90b938c 100644 --- a/uncloud/settings/__init__.py +++ b/uncloud/settings/__init__.py @@ -3,7 +3,7 @@ import logging import sys import os -from ucloud.common.etcd_wrapper import Etcd3Wrapper +from uncloud.common.etcd_wrapper import Etcd3Wrapper logger = logging.getLogger(__name__) @@ -14,7 +14,7 @@ class CustomConfigParser(configparser.RawConfigParser): result = super().__getitem__(key) except KeyError as err: raise KeyError( - "Key '{}' not found in configuration. Make sure you configure ucloud.".format( + "Key '{}' not found in configuration. Make sure you configure uncloud.".format( key ) ) from err @@ -24,9 +24,9 @@ class CustomConfigParser(configparser.RawConfigParser): class Settings(object): def __init__(self, config_key="/uncloud/config/"): - conf_name = "ucloud.conf" + conf_name = "uncloud.conf" conf_dir = os.environ.get( - "UCLOUD_CONF_DIR", os.path.expanduser("~/ucloud/") + "UCLOUD_CONF_DIR", os.path.expanduser("~/uncloud/") ) self.config_file = os.path.join(conf_dir, conf_name) @@ -109,7 +109,7 @@ class Settings(object): self.config_parser.read_dict(config_from_etcd.value) else: raise KeyError( - "Key '{}' not found in etcd. Please configure ucloud.".format( + "Key '{}' not found in etcd. Please configure uncloud.".format( self.config_key ) ) diff --git a/uncloud/shared/__init__.py b/uncloud/shared/__init__.py index 294e34a..db2093f 100644 --- a/uncloud/shared/__init__.py +++ b/uncloud/shared/__init__.py @@ -1,8 +1,8 @@ -from ucloud.settings import settings -from ucloud.common.vm import VmPool -from ucloud.common.host import HostPool -from ucloud.common.request import RequestPool -from ucloud.common.storage_handlers import get_storage_handler +from uncloud.settings import settings +from uncloud.common.vm import VmPool +from uncloud.common.host import HostPool +from uncloud.common.request import RequestPool +from uncloud.common.storage_handlers import get_storage_handler class Shared: diff --git a/uncloud/vmm/__init__.py b/uncloud/vmm/__init__.py index 4a7fc2f..6cdd938 100644 --- a/uncloud/vmm/__init__.py +++ b/uncloud/vmm/__init__.py @@ -102,7 +102,7 @@ class VMM: def __init__( self, qemu_path="/usr/bin/qemu-system-x86_64", - vmm_backend=os.path.expanduser("~/ucloud/vmm/"), + vmm_backend=os.path.expanduser("~/uncloud/vmm/"), ): self.qemu_path = qemu_path self.vmm_backend = vmm_backend From 6682f127f10b9c3ae1976af1e7aaf79dc6a3ad5f Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 31 Dec 2019 11:35:51 +0100 Subject: [PATCH 101/284] Remove colors, remove sophisticated logging Go back to simple --- scripts/uncloud | 16 ++++------------ uncloud/common/logging.py | 27 --------------------------- 2 files changed, 4 insertions(+), 39 deletions(-) delete mode 100644 uncloud/common/logging.py diff --git a/scripts/uncloud b/scripts/uncloud index 05e47a5..e5c4081 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -6,9 +6,7 @@ import multiprocessing as mp import sys from logging.handlers import SysLogHandler - -from ucloud.common.logging import NoTracebackStreamHandler -from ucloud.configure.main import configure_parser +from uncloud.configure.main import configure_parser def exception_hook(exc_type, exc_value, exc_traceback): @@ -31,19 +29,13 @@ if __name__ == '__main__': syslog_formatter = logging.Formatter('%(pathname)s:%(lineno)d -- %(levelname)-8s %(message)s') syslog_handler.setFormatter(syslog_formatter) - stream_handler = NoTracebackStreamHandler() - stream_handler.setLevel(logging.INFO) - stream_formatter = logging.Formatter('%(message)s') - stream_handler.setFormatter(stream_formatter) - logger.addHandler(syslog_handler) - logger.addHandler(stream_handler) arg_parser = argparse.ArgumentParser() subparsers = arg_parser.add_subparsers(dest="command") - + api_parser = subparsers.add_parser("api") - + host_parser = subparsers.add_parser("host") host_parser.add_argument("--hostname", required=True) @@ -72,7 +64,7 @@ if __name__ == '__main__': arguments = vars(args) try: name = arguments.pop('command') - mod = importlib.import_module("ucloud.{}.main".format(name)) + mod = importlib.import_module("uncloud.{}.main".format(name)) main = getattr(mod, "main") main(**arguments) except Exception as err: diff --git a/uncloud/common/logging.py b/uncloud/common/logging.py deleted file mode 100644 index 9e0d2be..0000000 --- a/uncloud/common/logging.py +++ /dev/null @@ -1,27 +0,0 @@ -import logging -import colorama - - -class NoTracebackStreamHandler(logging.StreamHandler): - def handle(self, record): - info, cache = record.exc_info, record.exc_text - record.exc_info, record.exc_text = None, None - - if record.levelname in ["WARNING", "WARN"]: - color = colorama.Fore.LIGHTYELLOW_EX - elif record.levelname == "ERROR": - color = colorama.Fore.LIGHTRED_EX - elif record.levelname == "INFO": - color = colorama.Fore.LIGHTGREEN_EX - elif record.levelname == "CRITICAL": - color = colorama.Fore.LIGHTCYAN_EX - else: - color = colorama.Fore.WHITE - - try: - print(color, end="", flush=True) - super().handle(record) - finally: - record.exc_info = info - record.exc_text = cache - print(colorama.Style.RESET_ALL, end="", flush=True) From 4c7678618dbf7480bad92eed86a49cd0cf554f7d Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 31 Dec 2019 11:37:52 +0100 Subject: [PATCH 102/284] Also fix setup.py and the configuration file --- conf/{ucloud.conf => uncloud.conf} | 0 setup.py | 8 ++++---- 2 files changed, 4 insertions(+), 4 deletions(-) rename conf/{ucloud.conf => uncloud.conf} (100%) diff --git a/conf/ucloud.conf b/conf/uncloud.conf similarity index 100% rename from conf/ucloud.conf rename to conf/uncloud.conf diff --git a/setup.py b/setup.py index b204f93..0764d74 100644 --- a/setup.py +++ b/setup.py @@ -6,9 +6,9 @@ with open("README.md", "r") as fh: long_description = fh.read() try: - import ucloud.version + import uncloud.version - version = ucloud.version.VERSION + version = uncloud.version.VERSION except: import subprocess @@ -42,9 +42,9 @@ setup( "etcd3 @ https://github.com/kragniz/python-etcd3/tarball/master#egg=etcd3", "marshmallow", ], - scripts=["scripts/ucloud"], + scripts=["scripts/uncloud"], data_files=[ - (os.path.expanduser("~/ucloud/"), ["conf/ucloud.conf"]) + (os.path.expanduser("~/uncloud/"), ["conf/uncloud.conf"]) ], zip_safe=False, ) From 1fba79ca319b4ee51650fc6176b8b3264cb3f43d Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 31 Dec 2019 11:56:28 +0100 Subject: [PATCH 103/284] remove syslog handler (cruft), add debug flag --- scripts/uncloud | 18 ++++++------------ uncloud/api/main.py | 2 +- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/scripts/uncloud b/scripts/uncloud index e5c4081..d22c6d0 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -22,32 +22,26 @@ sys.excepthook = exception_hook if __name__ == '__main__': # Setting up root logger logger = logging.getLogger() + logger.setLevel(logging.DEBUG) - syslog_handler = SysLogHandler(address='/dev/log') - syslog_handler.setLevel(logging.DEBUG) - syslog_formatter = logging.Formatter('%(pathname)s:%(lineno)d -- %(levelname)-8s %(message)s') - syslog_handler.setFormatter(syslog_formatter) - - logger.addHandler(syslog_handler) + parent_parser = argparse.ArgumentParser(add_help=False) + parent_parser.add_argument("--debug", "-d", action='store_true') arg_parser = argparse.ArgumentParser() + subparsers = arg_parser.add_subparsers(dest="command") - api_parser = subparsers.add_parser("api") - + api_parser = subparsers.add_parser("api", parents=[parent_parser]) host_parser = subparsers.add_parser("host") host_parser.add_argument("--hostname", required=True) scheduler_parser = subparsers.add_parser("scheduler") - filescanner_parser = subparsers.add_parser("filescanner") - imagescanner_parser = subparsers.add_parser("imagescanner") - metadata_parser = subparsers.add_parser("metadata") - config_parser = subparsers.add_parser("configure") + configure_parser(config_parser) args = arg_parser.parse_args() diff --git a/uncloud/api/main.py b/uncloud/api/main.py index 6ac5d44..37c6c5b 100644 --- a/uncloud/api/main.py +++ b/uncloud/api/main.py @@ -561,7 +561,7 @@ api.add_resource(ListHost, "/host/list") api.add_resource(CreateNetwork, "/network/create") -def main(): +def main(debug=False): image_stores = list( shared.etcd_client.get_prefix( settings["etcd"]["image_store_prefix"], value_in_json=True From bff12ed930a821d5ef988d73173e665f42798755 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 31 Dec 2019 12:15:05 +0100 Subject: [PATCH 104/284] ++ exception handling --- scripts/uncloud | 3 +++ uncloud/__init__.py | 2 ++ uncloud/common/etcd_wrapper.py | 7 ++++--- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/uncloud b/scripts/uncloud index d22c6d0..4625164 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -8,6 +8,7 @@ import sys from logging.handlers import SysLogHandler from uncloud.configure.main import configure_parser +from uncloud import UncloudException def exception_hook(exc_type, exc_value, exc_traceback): logging.getLogger(__name__).error( @@ -61,5 +62,7 @@ if __name__ == '__main__': mod = importlib.import_module("uncloud.{}.main".format(name)) main = getattr(mod, "main") main(**arguments) + except UncloudException as err: + logger.error(err) except Exception as err: logger.exception(err) diff --git a/uncloud/__init__.py b/uncloud/__init__.py index e69de29..2920f47 100644 --- a/uncloud/__init__.py +++ b/uncloud/__init__.py @@ -0,0 +1,2 @@ +class UncloudException(Exception): + pass diff --git a/uncloud/common/etcd_wrapper.py b/uncloud/common/etcd_wrapper.py index 7367a6c..6a979ba 100644 --- a/uncloud/common/etcd_wrapper.py +++ b/uncloud/common/etcd_wrapper.py @@ -2,6 +2,7 @@ import etcd3 import json import queue import copy +from uncloud import UncloudException from collections import namedtuple from functools import wraps @@ -29,9 +30,9 @@ def readable_errors(func): try: return func(*args, **kwargs) except etcd3.exceptions.ConnectionFailedError as err: - raise etcd3.exceptions.ConnectionFailedError( - "etcd connection failed." - ) from err + raise UncloudException( + "Cannot connect to etcd: is etcd running as configured in uncloud.conf?" + ) except etcd3.exceptions.ConnectionTimeoutError as err: raise etcd3.exceptions.ConnectionTimeoutError( "etcd connection timeout." From 29dfacfadb821c76c938d4079c21a5039c3f6f80 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 31 Dec 2019 12:15:50 +0100 Subject: [PATCH 105/284] Update .gitignore for uncloud --- .gitignore | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 82146fa..5c55899 100644 --- a/.gitignore +++ b/.gitignore @@ -2,17 +2,17 @@ .vscode -ucloud/docs/build +uncloud/docs/build logs.txt -ucloud.egg-info +uncloud.egg-info # run artefacts default.etcd __pycache__ # build artefacts -ucloud/version.py +uncloud/version.py build/ venv/ dist/ From 71c3f9d97870324e952fdac606e3a53ca72d047a Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 31 Dec 2019 13:13:19 +0100 Subject: [PATCH 106/284] begin adding port support, catch OSError from Flask --- scripts/uncloud | 2 ++ uncloud/api/main.py | 55 ++++++++++++++++++++++++++++----------------- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/scripts/uncloud b/scripts/uncloud index 4625164..28d8344 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -34,6 +34,8 @@ if __name__ == '__main__': subparsers = arg_parser.add_subparsers(dest="command") api_parser = subparsers.add_parser("api", parents=[parent_parser]) + api_parser.add_argument("--port", "-p") + host_parser = subparsers.add_parser("host") host_parser.add_argument("--hostname", required=True) diff --git a/uncloud/api/main.py b/uncloud/api/main.py index 37c6c5b..861c1bc 100644 --- a/uncloud/api/main.py +++ b/uncloud/api/main.py @@ -17,7 +17,7 @@ from uncloud.shared import shared from . import schemas from .helper import generate_mac, mac2ipv6 - +from uncloud import UncloudException logger = logging.getLogger(__name__) @@ -561,29 +561,42 @@ api.add_resource(ListHost, "/host/list") api.add_resource(CreateNetwork, "/network/create") -def main(debug=False): - image_stores = list( - shared.etcd_client.get_prefix( - settings["etcd"]["image_store_prefix"], value_in_json=True +def main(debug=False, port=None): + try: + image_stores = list( + shared.etcd_client.get_prefix( + settings["etcd"]["image_store_prefix"], value_in_json=True + ) ) - ) - if not image_stores: - data = { - "is_public": True, - "type": "ceph", - "name": "images", - "description": "first ever public image-store", - "attributes": {"list": [], "key": [], "pool": "images"}, - } + except KeyError: + image_stores = False - shared.etcd_client.put( - join_path( - settings["etcd"]["image_store_prefix"], uuid4().hex - ), - json.dumps(data), - ) + # Do not inject default values that might be very wrong + # fail when required, not before + # + # if not image_stores: + # data = { + # "is_public": True, + # "type": "ceph", + # "name": "images", + # "description": "first ever public image-store", + # "attributes": {"list": [], "key": [], "pool": "images"}, + # } - app.run(host="::", debug=False) + # shared.etcd_client.put( + # join_path( + # settings["etcd"]["image_store_prefix"], uuid4().hex + # ), + # json.dumps(data), + # ) + + if port: + app_port = port + + try: + app.run(host="::", debug=False) + except OSError as e: + raise UncloudException("Failed to start Flask: {}".format(e)) if __name__ == "__main__": From 9662e02eb79f598ad5eb50358bb91b9df9c53c64 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 31 Dec 2019 13:50:56 +0100 Subject: [PATCH 107/284] Allow to not have keys in etcd --- uncloud/settings/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/uncloud/settings/__init__.py b/uncloud/settings/__init__.py index 90b938c..629660e 100644 --- a/uncloud/settings/__init__.py +++ b/uncloud/settings/__init__.py @@ -115,7 +115,13 @@ class Settings(object): ) def __getitem__(self, key): - self.read_values_from_etcd() + # Allow failing to read from etcd if we have + # it locally + try: + self.read_values_from_etcd() + except KeyError as e: + pass + return self.config_parser[key] From e7755708846f62bb132a21b4380f24476804996a Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 31 Dec 2019 14:06:51 +0100 Subject: [PATCH 108/284] Make uncloud host running --- uncloud/api/main.py | 7 +++---- uncloud/host/main.py | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/uncloud/api/main.py b/uncloud/api/main.py index 861c1bc..93bada7 100644 --- a/uncloud/api/main.py +++ b/uncloud/api/main.py @@ -590,11 +590,10 @@ def main(debug=False, port=None): # json.dumps(data), # ) - if port: - app_port = port - try: - app.run(host="::", debug=False) + app.run(host="::", + port=port, + debug=debug) except OSError as e: raise UncloudException("Failed to start Flask: {}".format(e)) diff --git a/uncloud/host/main.py b/uncloud/host/main.py index 80527c9..d1e7c9a 100755 --- a/uncloud/host/main.py +++ b/uncloud/host/main.py @@ -1,6 +1,7 @@ import argparse import multiprocessing as mp import time +from uuid import uuid4 from uncloud.common.request import RequestEntry, RequestType from uncloud.shared import shared @@ -42,7 +43,21 @@ def maintenance(host): def main(hostname): host_pool = shared.host_pool host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) - assert host is not None, "No such host with name = {}".format(hostname) + + # Does not yet exist, create it + if not host: + host_key = join_path( + settings["etcd"]["host_prefix"], uuid4().hex + ) + host_entry = { + "specs": "", + "hostname": hostname, + "status": "DEAD", + "last_heartbeat": "", + } + shared.etcd_client.put( + host_key, host_entry, value_in_json=True + ) try: heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(hostname,)) From 2566e86f1e92647a0cd1e0552c2196a841b39abe Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 31 Dec 2019 14:13:08 +0100 Subject: [PATCH 109/284] [host] get ourselves from etcd --- uncloud/host/main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/uncloud/host/main.py b/uncloud/host/main.py index d1e7c9a..5ce9e6e 100755 --- a/uncloud/host/main.py +++ b/uncloud/host/main.py @@ -59,6 +59,9 @@ def main(hostname): host_key, host_entry, value_in_json=True ) + # update, get ourselves now for sure + host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) + try: heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(hostname,)) heartbeat_updating_process.start() From eb19b1033363322e10823d8461c7f6645c13eccd Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 31 Dec 2019 14:22:44 +0100 Subject: [PATCH 110/284] [scheduler] partial debug support --- scripts/uncloud | 3 ++- uncloud/scheduler/main.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/uncloud b/scripts/uncloud index 28d8344..5efc2a5 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -39,7 +39,8 @@ if __name__ == '__main__': host_parser = subparsers.add_parser("host") host_parser.add_argument("--hostname", required=True) - scheduler_parser = subparsers.add_parser("scheduler") + scheduler_parser = subparsers.add_parser("scheduler", parents=[parent_parser]) + filescanner_parser = subparsers.add_parser("filescanner") imagescanner_parser = subparsers.add_parser("imagescanner") metadata_parser = subparsers.add_parser("metadata") diff --git a/uncloud/scheduler/main.py b/uncloud/scheduler/main.py index 5a4014f..20fa0d6 100755 --- a/uncloud/scheduler/main.py +++ b/uncloud/scheduler/main.py @@ -16,7 +16,7 @@ from .helper import ( from . import logger -def main(): +def main(debug=False): for request_iterator in [ shared.etcd_client.get_prefix( settings["etcd"]["request_prefix"], value_in_json=True From b95037f624e60be4df96f57c49d61d83028296a0 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 31 Dec 2019 15:35:49 +0100 Subject: [PATCH 111/284] [metadata] allow passing in the port --- scripts/uncloud | 4 ++++ uncloud/metadata/main.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/uncloud b/scripts/uncloud index 5efc2a5..8add1d6 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -41,9 +41,13 @@ if __name__ == '__main__': scheduler_parser = subparsers.add_parser("scheduler", parents=[parent_parser]) + filescanner_parser = subparsers.add_parser("filescanner") imagescanner_parser = subparsers.add_parser("imagescanner") + metadata_parser = subparsers.add_parser("metadata") + metadata_parser.add_argument("--port", "-p") + config_parser = subparsers.add_parser("configure") configure_parser(config_parser) diff --git a/uncloud/metadata/main.py b/uncloud/metadata/main.py index da993ae..389b9a0 100644 --- a/uncloud/metadata/main.py +++ b/uncloud/metadata/main.py @@ -111,8 +111,8 @@ class Root(Resource): api.add_resource(Root, "/") -def main(): - app.run(debug=True, host="::", port="80") +def main(port=None): + app.run(debug=True, host="::", port=port) if __name__ == "__main__": From 2afb37daca75faede6fd14f4bd2b6a615967e2d7 Mon Sep 17 00:00:00 2001 From: meow Date: Tue, 31 Dec 2019 20:33:55 +0500 Subject: [PATCH 112/284] get() methods converted to post() --- uncloud/api/main.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/uncloud/api/main.py b/uncloud/api/main.py index 93bada7..1cb736f 100644 --- a/uncloud/api/main.py +++ b/uncloud/api/main.py @@ -90,7 +90,7 @@ class CreateVM(Resource): class VmStatus(Resource): @staticmethod - def get(): + def post(): data = request.json validator = schemas.VMStatusSchema(data) if validator.is_valid(): @@ -244,7 +244,7 @@ class VMMigration(Resource): class ListUserVM(Resource): @staticmethod - def get(): + def post(): data = request.json validator = schemas.OTPSchema(data) @@ -277,7 +277,7 @@ class ListUserVM(Resource): class ListUserFiles(Resource): @staticmethod - def get(): + def post(): data = request.json validator = schemas.OTPSchema(data) @@ -344,7 +344,7 @@ class ListHost(Resource): class GetSSHKeys(Resource): @staticmethod - def get(): + def post(): data = request.json validator = schemas.GetSSHSchema(data) if validator.is_valid(): @@ -430,7 +430,7 @@ class AddSSHKey(Resource): class RemoveSSHKey(Resource): @staticmethod - def get(): + def post(): data = request.json validator = schemas.RemoveSSHSchema(data) if validator.is_valid(): @@ -518,7 +518,7 @@ class CreateNetwork(Resource): class ListUserNetwork(Resource): @staticmethod - def get(): + def post(): data = request.json validator = schemas.OTPSchema(data) From cd2f0aaa0d84f0844a2af2d019a572625d3dcf01 Mon Sep 17 00:00:00 2001 From: meow Date: Wed, 1 Jan 2020 14:59:47 +0500 Subject: [PATCH 113/284] Using click instead of argparse in uncloud script --- uncloud/cli/__init__.py | 0 uncloud/cli/commands/__init__.py | 0 uncloud/cli/commands/helper.py | 62 +++++++++++++++++++++++++++++++ uncloud/cli/commands/host.py | 31 ++++++++++++++++ uncloud/cli/commands/image.py | 24 ++++++++++++ uncloud/cli/commands/network.py | 17 +++++++++ uncloud/cli/commands/user.py | 48 ++++++++++++++++++++++++ uncloud/cli/commands/vm.py | 63 ++++++++++++++++++++++++++++++++ uncloud/cli/main.py | 24 ++++++++++++ uncloud/filescanner/main.py | 2 +- uncloud/host/main.py | 2 +- uncloud/imagescanner/main.py | 2 +- uncloud/metadata/main.py | 4 +- uncloud/scheduler/main.py | 20 +++------- 14 files changed, 279 insertions(+), 20 deletions(-) create mode 100644 uncloud/cli/__init__.py create mode 100755 uncloud/cli/commands/__init__.py create mode 100755 uncloud/cli/commands/helper.py create mode 100755 uncloud/cli/commands/host.py create mode 100755 uncloud/cli/commands/image.py create mode 100644 uncloud/cli/commands/network.py create mode 100755 uncloud/cli/commands/user.py create mode 100755 uncloud/cli/commands/vm.py create mode 100644 uncloud/cli/main.py diff --git a/uncloud/cli/__init__.py b/uncloud/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/cli/commands/__init__.py b/uncloud/cli/commands/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/uncloud/cli/commands/helper.py b/uncloud/cli/commands/helper.py new file mode 100755 index 0000000..6bef690 --- /dev/null +++ b/uncloud/cli/commands/helper.py @@ -0,0 +1,62 @@ +import json +import binascii +import click +import requests + +from os.path import join as join_path + +from pyotp import TOTP +from uncloud.settings import settings + + +def load_dump_pretty(content): + if isinstance(content, bytes): + content = content.decode('utf-8') + parsed = json.loads(content) + return json.dumps(parsed, indent=4, sort_keys=True) + + +def make_request(*args, data=None, request_method=requests.post): + r = request_method( + join_path(settings['client']['api_server'], *args), json=data + ) + try: + print(load_dump_pretty(r.content)) + except Exception: + print('Error occurred while getting output from api server.') + + +def get_token(_, param, value): + if value is not None: + try: + token = TOTP(value).now() + except binascii.Error: + raise click.BadParameter('') + else: + param.name = 'token' + return token + + +def add_otp_options(f): + options = [ + click.option('--name', show_default='name mentioned in config file.', prompt=True), + click.option('--realm', show_default='realm mentioned in config file.', prompt=True), + click.option('--seed', callback=get_token, show_default='seed mentioned in config file', + prompt=True) + ] + + for opt in reversed(options): + f = opt(f) + + return f + + +def add_vm_options(f): + options = [ + click.option('--vm-name', required=True), + click.option('--action', required=True, default=f.__name__) + ] + for opt in reversed(options): + f = opt(f) + + return f diff --git a/uncloud/cli/commands/host.py b/uncloud/cli/commands/host.py new file mode 100755 index 0000000..29ee417 --- /dev/null +++ b/uncloud/cli/commands/host.py @@ -0,0 +1,31 @@ +import click +import requests + +from .helper import add_otp_options, make_request + + +@click.group() +def host(): + pass + + +@host.command('create') +@add_otp_options +@click.option('--hostname', required=True) +@click.option('--cpu', required=True, type=int) +@click.option('--ram', required=True) +@click.option('--os-ssd', required=True) +@click.option('--hdd', default=list(), multiple=True) +def create(**kwargs): + kwargs['specs'] = { + 'cpu': kwargs.pop('cpu'), + 'ram': kwargs.pop('ram'), + 'os-ssd': kwargs.pop('os_ssd'), + 'hdd': kwargs.pop('hdd') + } + make_request('host', 'create', data=kwargs) + + +@host.command('list') +def list_host(): + make_request('host', 'list', request_method=requests.get) diff --git a/uncloud/cli/commands/image.py b/uncloud/cli/commands/image.py new file mode 100755 index 0000000..6f9fe86 --- /dev/null +++ b/uncloud/cli/commands/image.py @@ -0,0 +1,24 @@ +import click +import requests + +from .helper import make_request + + +@click.group() +def image(): + pass + + +@image.command('list') +@click.option('--public', is_flag=True) +def _list(public): + if public: + make_request('image', 'list-public', request_method=requests.get) + + +@image.command('create-from-file') +@click.option('--name', required=True) +@click.option('--uuid', required=True) +@click.option('--image-store-name', 'image_store', required=True) +def create_from_file(**kwargs): + make_request('image', 'create', data=kwargs) diff --git a/uncloud/cli/commands/network.py b/uncloud/cli/commands/network.py new file mode 100644 index 0000000..81e22d5 --- /dev/null +++ b/uncloud/cli/commands/network.py @@ -0,0 +1,17 @@ +import click + +from .helper import add_otp_options, make_request + + +@click.group() +def network(): + pass + + +@network.command('create') +@add_otp_options +@click.option('--network-name', required=True) +@click.option('--network-type', 'type', required=True) +@click.option('--user', required=True, type=bool, default=False) +def create(**kwargs): + make_request('network', 'create', data=kwargs) diff --git a/uncloud/cli/commands/user.py b/uncloud/cli/commands/user.py new file mode 100755 index 0000000..2116520 --- /dev/null +++ b/uncloud/cli/commands/user.py @@ -0,0 +1,48 @@ +import click + +from .helper import add_otp_options, make_request + + +@click.group() +def user(): + pass + + +@user.command('files') +@add_otp_options +def list_files(**kwargs): + make_request('user', 'files', data=kwargs) + + +@user.command('vms') +@add_otp_options +def list_vms(**kwargs): + make_request('user', 'vms', data=kwargs) + + +@user.command('networks') +@add_otp_options +def list_networks(**kwargs): + make_request('user', 'network', data=kwargs) + + +@user.command('add-ssh') +@add_otp_options +@click.option('--key-name', required=True) +@click.option('--key', required=True) +def add_ssh(**kwargs): + make_request('user', 'add-ssh', data=kwargs) + + +@user.command('remove-ssh') +@add_otp_options +@click.option('--key-name', required=True) +def remove_ssh(**kwargs): + make_request('user', 'remove-ssh', data=kwargs) + + +@user.command('get-ssh') +@add_otp_options +@click.option('--key-name', default='') +def get_ssh(**kwargs): + make_request('user', 'get-ssh', data=kwargs) diff --git a/uncloud/cli/commands/vm.py b/uncloud/cli/commands/vm.py new file mode 100755 index 0000000..8527ac2 --- /dev/null +++ b/uncloud/cli/commands/vm.py @@ -0,0 +1,63 @@ +import click + +from .helper import add_otp_options, make_request, add_vm_options + + +@click.group() +def vm(): + pass + + +@vm.command('create') +@add_otp_options +@add_vm_options +@click.option('--cpu', required=True, type=int) +@click.option('--ram', required=True) +@click.option('--os-ssd', required=True) +@click.option('--hdd', default=list(), multiple=True) +@click.option('--image', required=True) +@click.option('--network', default=list(), multiple=True) +def create(**kwargs): + kwargs['specs'] = { + 'cpu': kwargs.pop('cpu'), + 'ram': kwargs.pop('ram'), + 'os-ssd': kwargs.pop('os_ssd'), + 'hdd': kwargs.pop('hdd') + } + make_request('vm', kwargs.pop('action'), data=kwargs) + + +@vm.command('start') +@add_otp_options +@add_vm_options +def start(**kwargs): + make_request('vm', 'action', data=kwargs) + + +@vm.command('stop') +@add_otp_options +@add_vm_options +def stop(**kwargs): + make_request('vm', 'action', data=kwargs) + + +@vm.command('delete') +@add_otp_options +@add_vm_options +def delete(**kwargs): + make_request('vm', 'action', data=kwargs) + + +@vm.command('status') +@add_otp_options +@click.option('--vm-name', required=True) +def status(**kwargs): + make_request('vm', 'status', data=kwargs) + + +@vm.command('migrate') +@add_otp_options +@click.option('--vm-name', required=True) +@click.option('--destination', required=True) +def vm_migration(**kwargs): + make_request('vm', 'migrate', data=kwargs) diff --git a/uncloud/cli/main.py b/uncloud/cli/main.py new file mode 100644 index 0000000..7d625c4 --- /dev/null +++ b/uncloud/cli/main.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 + +import click + +from uncloud.cli.commands.vm import vm +from uncloud.cli.commands.user import user +from uncloud.cli.commands.host import host +from uncloud.cli.commands.image import image +from uncloud.cli.commands.network import network + + +@click.group() +def cli(): + pass + + +cli.add_command(vm) +cli.add_command(user) +cli.add_command(image) +cli.add_command(host) +cli.add_command(network) + +if __name__ == '__main__': + cli() diff --git a/uncloud/filescanner/main.py b/uncloud/filescanner/main.py index 7ce8654..bb318c3 100755 --- a/uncloud/filescanner/main.py +++ b/uncloud/filescanner/main.py @@ -67,7 +67,7 @@ def track_file(file, base_dir): os.setxattr(file, "user.utracked", b"True") -def main(): +def main(debug=False): base_dir = settings["storage"]["file_dir"] # Recursively Get All Files and Folder below BASE_DIR diff --git a/uncloud/host/main.py b/uncloud/host/main.py index 5ce9e6e..e469725 100755 --- a/uncloud/host/main.py +++ b/uncloud/host/main.py @@ -40,7 +40,7 @@ def maintenance(host): shared.vm_pool.put(vm) -def main(hostname): +def main(hostname, debug=False): host_pool = shared.host_pool host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) diff --git a/uncloud/imagescanner/main.py b/uncloud/imagescanner/main.py index 93e4dd5..fc77809 100755 --- a/uncloud/imagescanner/main.py +++ b/uncloud/imagescanner/main.py @@ -26,7 +26,7 @@ def qemu_img_type(path): return qemu_img_info["format"] -def main(): +def main(debug=False): # We want to get images entries that requests images to be created images = shared.etcd_client.get_prefix( settings["etcd"]["image_prefix"], value_in_json=True diff --git a/uncloud/metadata/main.py b/uncloud/metadata/main.py index 389b9a0..a59a998 100644 --- a/uncloud/metadata/main.py +++ b/uncloud/metadata/main.py @@ -111,8 +111,8 @@ class Root(Resource): api.add_resource(Root, "/") -def main(port=None): - app.run(debug=True, host="::", port=port) +def main(port=None, debug=False): + app.run(debug=debug, host="::", port=port) if __name__ == "__main__": diff --git a/uncloud/scheduler/main.py b/uncloud/scheduler/main.py index 20fa0d6..79b1edc 100755 --- a/uncloud/scheduler/main.py +++ b/uncloud/scheduler/main.py @@ -43,29 +43,19 @@ def main(debug=False): dead_host_mitigation(dead_hosts) elif request_entry.type == RequestType.ScheduleVM: - print(request_event.value) - logger.debug( - "%s, %s", request_entry.key, request_entry.value - ) + logger.debug("%s, %s", request_entry.key, request_entry.value) vm_entry = shared.vm_pool.get(request_entry.uuid) if vm_entry is None: - logger.info( - "Trying to act on {} but it is deleted".format( - request_entry.uuid - ) - ) + logger.info("Trying to act on {} but it is deleted".format(request_entry.uuid)) continue - shared.etcd_client.client.delete( - request_entry.key - ) # consume Request + + shared.etcd_client.client.delete(request_entry.key) # consume Request try: assign_host(vm_entry) except NoSuitableHostFound: - vm_entry.add_log( - "Can't schedule VM. No Resource Left." - ) + vm_entry.add_log("Can't schedule VM. No Resource Left.") shared.vm_pool.put(vm_entry) logger.info("No Resource Left. Emailing admin....") From 50fb135726a17b4bf63e7e691ec34bcebca05fe9 Mon Sep 17 00:00:00 2001 From: meow Date: Fri, 3 Jan 2020 15:02:39 +0500 Subject: [PATCH 114/284] uncloud cli converted to argparse, code isn't beautiful yet. Would make it soom --- uncloud/cli/commands/helper.py | 62 ------------------ uncloud/cli/commands/host.py | 31 --------- uncloud/cli/commands/image.py | 24 ------- uncloud/cli/commands/network.py | 17 ----- uncloud/cli/commands/user.py | 48 -------------- uncloud/cli/commands/vm.py | 63 ------------------- uncloud/cli/helper.py | 42 +++++++++++++ uncloud/cli/host.py | 45 +++++++++++++ uncloud/cli/image.py | 38 +++++++++++ uncloud/cli/main.py | 37 ++++++----- uncloud/cli/network.py | 32 ++++++++++ uncloud/cli/user.py | 41 ++++++++++++ uncloud/cli/vm.py | 62 ++++++++++++++++++ .../commands/__init__.py => common/parser.py} | 0 14 files changed, 278 insertions(+), 264 deletions(-) delete mode 100755 uncloud/cli/commands/helper.py delete mode 100755 uncloud/cli/commands/host.py delete mode 100755 uncloud/cli/commands/image.py delete mode 100644 uncloud/cli/commands/network.py delete mode 100755 uncloud/cli/commands/user.py delete mode 100755 uncloud/cli/commands/vm.py create mode 100644 uncloud/cli/helper.py create mode 100644 uncloud/cli/host.py create mode 100644 uncloud/cli/image.py create mode 100644 uncloud/cli/network.py create mode 100755 uncloud/cli/user.py create mode 100644 uncloud/cli/vm.py rename uncloud/{cli/commands/__init__.py => common/parser.py} (100%) mode change 100755 => 100644 diff --git a/uncloud/cli/commands/helper.py b/uncloud/cli/commands/helper.py deleted file mode 100755 index 6bef690..0000000 --- a/uncloud/cli/commands/helper.py +++ /dev/null @@ -1,62 +0,0 @@ -import json -import binascii -import click -import requests - -from os.path import join as join_path - -from pyotp import TOTP -from uncloud.settings import settings - - -def load_dump_pretty(content): - if isinstance(content, bytes): - content = content.decode('utf-8') - parsed = json.loads(content) - return json.dumps(parsed, indent=4, sort_keys=True) - - -def make_request(*args, data=None, request_method=requests.post): - r = request_method( - join_path(settings['client']['api_server'], *args), json=data - ) - try: - print(load_dump_pretty(r.content)) - except Exception: - print('Error occurred while getting output from api server.') - - -def get_token(_, param, value): - if value is not None: - try: - token = TOTP(value).now() - except binascii.Error: - raise click.BadParameter('') - else: - param.name = 'token' - return token - - -def add_otp_options(f): - options = [ - click.option('--name', show_default='name mentioned in config file.', prompt=True), - click.option('--realm', show_default='realm mentioned in config file.', prompt=True), - click.option('--seed', callback=get_token, show_default='seed mentioned in config file', - prompt=True) - ] - - for opt in reversed(options): - f = opt(f) - - return f - - -def add_vm_options(f): - options = [ - click.option('--vm-name', required=True), - click.option('--action', required=True, default=f.__name__) - ] - for opt in reversed(options): - f = opt(f) - - return f diff --git a/uncloud/cli/commands/host.py b/uncloud/cli/commands/host.py deleted file mode 100755 index 29ee417..0000000 --- a/uncloud/cli/commands/host.py +++ /dev/null @@ -1,31 +0,0 @@ -import click -import requests - -from .helper import add_otp_options, make_request - - -@click.group() -def host(): - pass - - -@host.command('create') -@add_otp_options -@click.option('--hostname', required=True) -@click.option('--cpu', required=True, type=int) -@click.option('--ram', required=True) -@click.option('--os-ssd', required=True) -@click.option('--hdd', default=list(), multiple=True) -def create(**kwargs): - kwargs['specs'] = { - 'cpu': kwargs.pop('cpu'), - 'ram': kwargs.pop('ram'), - 'os-ssd': kwargs.pop('os_ssd'), - 'hdd': kwargs.pop('hdd') - } - make_request('host', 'create', data=kwargs) - - -@host.command('list') -def list_host(): - make_request('host', 'list', request_method=requests.get) diff --git a/uncloud/cli/commands/image.py b/uncloud/cli/commands/image.py deleted file mode 100755 index 6f9fe86..0000000 --- a/uncloud/cli/commands/image.py +++ /dev/null @@ -1,24 +0,0 @@ -import click -import requests - -from .helper import make_request - - -@click.group() -def image(): - pass - - -@image.command('list') -@click.option('--public', is_flag=True) -def _list(public): - if public: - make_request('image', 'list-public', request_method=requests.get) - - -@image.command('create-from-file') -@click.option('--name', required=True) -@click.option('--uuid', required=True) -@click.option('--image-store-name', 'image_store', required=True) -def create_from_file(**kwargs): - make_request('image', 'create', data=kwargs) diff --git a/uncloud/cli/commands/network.py b/uncloud/cli/commands/network.py deleted file mode 100644 index 81e22d5..0000000 --- a/uncloud/cli/commands/network.py +++ /dev/null @@ -1,17 +0,0 @@ -import click - -from .helper import add_otp_options, make_request - - -@click.group() -def network(): - pass - - -@network.command('create') -@add_otp_options -@click.option('--network-name', required=True) -@click.option('--network-type', 'type', required=True) -@click.option('--user', required=True, type=bool, default=False) -def create(**kwargs): - make_request('network', 'create', data=kwargs) diff --git a/uncloud/cli/commands/user.py b/uncloud/cli/commands/user.py deleted file mode 100755 index 2116520..0000000 --- a/uncloud/cli/commands/user.py +++ /dev/null @@ -1,48 +0,0 @@ -import click - -from .helper import add_otp_options, make_request - - -@click.group() -def user(): - pass - - -@user.command('files') -@add_otp_options -def list_files(**kwargs): - make_request('user', 'files', data=kwargs) - - -@user.command('vms') -@add_otp_options -def list_vms(**kwargs): - make_request('user', 'vms', data=kwargs) - - -@user.command('networks') -@add_otp_options -def list_networks(**kwargs): - make_request('user', 'network', data=kwargs) - - -@user.command('add-ssh') -@add_otp_options -@click.option('--key-name', required=True) -@click.option('--key', required=True) -def add_ssh(**kwargs): - make_request('user', 'add-ssh', data=kwargs) - - -@user.command('remove-ssh') -@add_otp_options -@click.option('--key-name', required=True) -def remove_ssh(**kwargs): - make_request('user', 'remove-ssh', data=kwargs) - - -@user.command('get-ssh') -@add_otp_options -@click.option('--key-name', default='') -def get_ssh(**kwargs): - make_request('user', 'get-ssh', data=kwargs) diff --git a/uncloud/cli/commands/vm.py b/uncloud/cli/commands/vm.py deleted file mode 100755 index 8527ac2..0000000 --- a/uncloud/cli/commands/vm.py +++ /dev/null @@ -1,63 +0,0 @@ -import click - -from .helper import add_otp_options, make_request, add_vm_options - - -@click.group() -def vm(): - pass - - -@vm.command('create') -@add_otp_options -@add_vm_options -@click.option('--cpu', required=True, type=int) -@click.option('--ram', required=True) -@click.option('--os-ssd', required=True) -@click.option('--hdd', default=list(), multiple=True) -@click.option('--image', required=True) -@click.option('--network', default=list(), multiple=True) -def create(**kwargs): - kwargs['specs'] = { - 'cpu': kwargs.pop('cpu'), - 'ram': kwargs.pop('ram'), - 'os-ssd': kwargs.pop('os_ssd'), - 'hdd': kwargs.pop('hdd') - } - make_request('vm', kwargs.pop('action'), data=kwargs) - - -@vm.command('start') -@add_otp_options -@add_vm_options -def start(**kwargs): - make_request('vm', 'action', data=kwargs) - - -@vm.command('stop') -@add_otp_options -@add_vm_options -def stop(**kwargs): - make_request('vm', 'action', data=kwargs) - - -@vm.command('delete') -@add_otp_options -@add_vm_options -def delete(**kwargs): - make_request('vm', 'action', data=kwargs) - - -@vm.command('status') -@add_otp_options -@click.option('--vm-name', required=True) -def status(**kwargs): - make_request('vm', 'status', data=kwargs) - - -@vm.command('migrate') -@add_otp_options -@click.option('--vm-name', required=True) -@click.option('--destination', required=True) -def vm_migration(**kwargs): - make_request('vm', 'migrate', data=kwargs) diff --git a/uncloud/cli/helper.py b/uncloud/cli/helper.py new file mode 100644 index 0000000..bdcce78 --- /dev/null +++ b/uncloud/cli/helper.py @@ -0,0 +1,42 @@ +import requests +import json +import argparse +import binascii + +from pyotp import TOTP +from os.path import join as join_path +from uncloud.settings import settings + + +def get_otp_parser(): + otp_parser = argparse.ArgumentParser('otp') + otp_parser.add_argument('--name', default=settings['client']['name']) + otp_parser.add_argument('--realm', default=settings['client']['realm']) + otp_parser.add_argument('--seed', type=get_token, default=settings['client']['seed'], + dest='token', metavar='SEED') + return otp_parser + + +def load_dump_pretty(content): + if isinstance(content, bytes): + content = content.decode('utf-8') + parsed = json.loads(content) + return json.dumps(parsed, indent=4, sort_keys=True) + + +def make_request(*args, data=None, request_method=requests.post): + r = request_method(join_path(settings['client']['api_server'], *args), json=data) + try: + print(load_dump_pretty(r.content)) + except Exception: + print('Error occurred while getting output from api server.') + + +def get_token(seed): + if seed is not None: + try: + token = TOTP(seed).now() + except binascii.Error: + raise argparse.ArgumentTypeError('Invalid seed') + else: + return token diff --git a/uncloud/cli/host.py b/uncloud/cli/host.py new file mode 100644 index 0000000..e912567 --- /dev/null +++ b/uncloud/cli/host.py @@ -0,0 +1,45 @@ +import requests + +from uncloud.cli.helper import make_request, get_otp_parser +from uncloud.common.parser import BaseParser + + +class HostParser(BaseParser): + def __init__(self): + super().__init__('host') + + def create(self, **kwargs): + p = self.subparser.add_parser('create', parents=[get_otp_parser()], **kwargs) + p.add_argument('--hostname', required=True) + p.add_argument('--cpu', required=True, type=int) + p.add_argument('--ram', required=True) + p.add_argument('--os-ssd', required=True) + p.add_argument('--hdd', default=list()) + + def list(self, **kwargs): + self.subparser.add_parser('list', **kwargs) + + +parser = HostParser() +arg_parser = parser.arg_parser + + +def main(**kwargs): + subcommand = kwargs.pop('host_subcommand') + if not subcommand: + arg_parser.print_help() + else: + request_method = requests.post + data = None + if subcommand == 'create': + kwargs['specs'] = { + 'cpu': kwargs.pop('cpu'), + 'ram': kwargs.pop('ram'), + 'os-ssd': kwargs.pop('os_ssd'), + 'hdd': kwargs.pop('hdd') + } + data = kwargs + elif subcommand == 'list': + request_method = requests.get + + make_request('host', subcommand, data=data, request_method=request_method) diff --git a/uncloud/cli/image.py b/uncloud/cli/image.py new file mode 100644 index 0000000..3db9577 --- /dev/null +++ b/uncloud/cli/image.py @@ -0,0 +1,38 @@ +import requests + +from uncloud.cli.helper import make_request +from uncloud.common.parser import BaseParser + + +class ImageParser(BaseParser): + def __init__(self): + super().__init__('image') + + def create(self, **kwargs): + p = self.subparser.add_parser('create', **kwargs) + p.add_argument('--name', required=True) + p.add_argument('--uuid', required=True) + p.add_argument('--image-store-name', default='image_store') + + def list(self, **kwargs): + self.subparser.add_parser('list', add_help=False, **kwargs) + + +parser = ImageParser() +arg_parser = parser.arg_parser + + +def main(**kwargs): + subcommand = kwargs.pop('image_subcommand') + if not subcommand: + arg_parser.print_help() + else: + data = None + request_method = requests.post + if subcommand == 'list': + subcommand = 'list-public' + request_method = requests.get + elif subcommand == 'create': + data = kwargs + + make_request('image', subcommand, data=data, request_method=request_method) diff --git a/uncloud/cli/main.py b/uncloud/cli/main.py index 7d625c4..7f5e367 100644 --- a/uncloud/cli/main.py +++ b/uncloud/cli/main.py @@ -1,24 +1,23 @@ #!/usr/bin/env python3 -import click +import argparse +import importlib -from uncloud.cli.commands.vm import vm -from uncloud.cli.commands.user import user -from uncloud.cli.commands.host import host -from uncloud.cli.commands.image import image -from uncloud.cli.commands.network import network +arg_parser = argparse.ArgumentParser('cli', add_help=False) +subparser = arg_parser.add_subparsers(dest='subcommand') + +for component in ['user', 'host', 'image', 'network', 'vm']: + module = importlib.import_module('uncloud.cli.{}'.format(component)) + parser = getattr(module, 'arg_parser') + subparser.add_parser(name=parser.prog, parents=[parser]) -@click.group() -def cli(): - pass - - -cli.add_command(vm) -cli.add_command(user) -cli.add_command(image) -cli.add_command(host) -cli.add_command(network) - -if __name__ == '__main__': - cli() +def main(**kwargs): + if not kwargs['subcommand']: + arg_parser.print_help() + else: + name = kwargs.pop('subcommand') + kwargs.pop('debug') + mod = importlib.import_module('uncloud.cli.{}'.format(name)) + _main = getattr(mod, 'main') + _main(**kwargs) diff --git a/uncloud/cli/network.py b/uncloud/cli/network.py new file mode 100644 index 0000000..33e41a9 --- /dev/null +++ b/uncloud/cli/network.py @@ -0,0 +1,32 @@ +import requests + +from uncloud.cli.helper import make_request, get_otp_parser +from uncloud.common.parser import BaseParser + + +class NetworkParser(BaseParser): + def __init__(self): + super().__init__('network') + + def create(self, **kwargs): + p = self.subparser.add_parser('create', add_help=False, parents=[get_otp_parser()], **kwargs) + p.add_argument('--network-name', required=True) + p.add_argument('--network-type', required=True, dest='type') + p.add_argument('--user', action='store_true') + + +parser = NetworkParser() +arg_parser = parser.arg_parser + + +def main(**kwargs): + subcommand = kwargs.pop('network_subcommand') + if not subcommand: + arg_parser.print_help() + else: + data = None + request_method = requests.post + if subcommand == 'create': + data = kwargs + + make_request('network', subcommand, data=data, request_method=request_method) diff --git a/uncloud/cli/user.py b/uncloud/cli/user.py new file mode 100755 index 0000000..3a4cc4e --- /dev/null +++ b/uncloud/cli/user.py @@ -0,0 +1,41 @@ +from uncloud.cli.helper import make_request, get_otp_parser +from uncloud.common.parser import BaseParser + + +class UserParser(BaseParser): + def __init__(self): + super().__init__('user') + + def files(self, **kwargs): + self.subparser.add_parser('files', parents=[get_otp_parser()], **kwargs) + + def vms(self, **kwargs): + self.subparser.add_parser('vms', parents=[get_otp_parser()], **kwargs) + + def networks(self, **kwargs): + self.subparser.add_parser('networks', parents=[get_otp_parser()], **kwargs) + + def add_ssh(self, **kwargs): + p = self.subparser.add_parser('add-ssh', parents=[get_otp_parser()], **kwargs) + p.add_argument('--key-name', required=True) + p.add_argument('--key', required=True) + + def get_ssh(self, **kwargs): + p = self.subparser.add_parser('get-ssh', parents=[get_otp_parser()], **kwargs) + p.add_argument('--key-name', default='') + + def remove_ssh(self, **kwargs): + p = self.subparser.add_parser('remove-ssh', parents=[get_otp_parser()], **kwargs) + p.add_argument('--key-name', required=True) + + +parser = UserParser() +arg_parser = parser.arg_parser + + +def main(**kwargs): + subcommand = kwargs.pop('user_subcommand') + if not subcommand: + arg_parser.print_help() + else: + make_request('user', subcommand, data=kwargs) diff --git a/uncloud/cli/vm.py b/uncloud/cli/vm.py new file mode 100644 index 0000000..396530e --- /dev/null +++ b/uncloud/cli/vm.py @@ -0,0 +1,62 @@ +from uncloud.common.parser import BaseParser +from uncloud.cli.helper import make_request, get_otp_parser + + +class VMParser(BaseParser): + def __init__(self): + super().__init__('vm') + + def start(self, **args): + p = self.subparser.add_parser('start', parents=[get_otp_parser()], **args) + p.add_argument('--vm-name', required=True) + + def stop(self, **args): + p = self.subparser.add_parser('stop', parents=[get_otp_parser()], **args) + p.add_argument('--vm-name', required=True) + + def status(self, **args): + p = self.subparser.add_parser('status', parents=[get_otp_parser()], **args) + p.add_argument('--vm-name', required=True) + + def delete(self, **args): + p = self.subparser.add_parser('delete', parents=[get_otp_parser()], **args) + p.add_argument('--vm-name', required=True) + + def migrate(self, **args): + p = self.subparser.add_parser('migrate', parents=[get_otp_parser()], **args) + p.add_argument('--vm-name', required=True) + p.add_argument('--destination', required=True) + + def create(self, **args): + p = self.subparser.add_parser('create', parents=[get_otp_parser()], **args) + p.add_argument('--cpu', required=True) + p.add_argument('--ram', required=True) + p.add_argument('--os-ssd', required=True) + p.add_argument('--hdd', action='append', default=list()) + p.add_argument('--image', required=True) + p.add_argument('--network', action='append', default=[]) + p.add_argument('--vm-name', required=True) + + +parser = VMParser() +arg_parser = parser.arg_parser + + +def main(**kwargs): + subcommand = kwargs.pop('vm_subcommand') + if not subcommand: + arg_parser.print_help() + else: + data = kwargs + endpoint = subcommand + if subcommand in ['start', 'stop', 'delete']: + endpoint = 'action' + data['action'] = subcommand + elif subcommand == 'create': + kwargs['specs'] = { + 'cpu': kwargs.pop('cpu'), + 'ram': kwargs.pop('ram'), + 'os-ssd': kwargs.pop('os_ssd'), + 'hdd': kwargs.pop('hdd') + } + make_request('vm', endpoint, data=data) diff --git a/uncloud/cli/commands/__init__.py b/uncloud/common/parser.py old mode 100755 new mode 100644 similarity index 100% rename from uncloud/cli/commands/__init__.py rename to uncloud/common/parser.py From 3296e524cc3e2d2d36be68072425e54a2ece74e1 Mon Sep 17 00:00:00 2001 From: meow Date: Fri, 3 Jan 2020 18:38:59 +0500 Subject: [PATCH 115/284] uncloud cli converted to argparse --- conf/uncloud.conf | 6 +- scripts/uncloud | 49 ++---- setup.py | 2 +- uncloud/api/main.py | 331 ++++++++++++++++++----------------- uncloud/api/schemas.py | 18 +- uncloud/common/parser.py | 13 ++ uncloud/configure/main.py | 91 ++++------ uncloud/filescanner/main.py | 4 + uncloud/host/main.py | 4 + uncloud/imagescanner/main.py | 4 + uncloud/metadata/main.py | 4 + uncloud/scheduler/main.py | 11 +- uncloud/settings/__init__.py | 34 ++-- 13 files changed, 284 insertions(+), 287 deletions(-) diff --git a/conf/uncloud.conf b/conf/uncloud.conf index 334bbeb..9d4358d 100644 --- a/conf/uncloud.conf +++ b/conf/uncloud.conf @@ -1,7 +1,11 @@ [etcd] url = localhost port = 2379 - ca_cert cert_cert cert_key + +[client] +name = replace_me +realm = replace_me +seed = replace_me diff --git a/scripts/uncloud b/scripts/uncloud index 8add1d6..968ace6 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -1,14 +1,13 @@ #!/usr/bin/env python3 -import argparse import logging -import importlib -import multiprocessing as mp import sys - -from logging.handlers import SysLogHandler -from uncloud.configure.main import configure_parser +import importlib +import argparse +import multiprocessing as mp from uncloud import UncloudException +from contextlib import suppress + def exception_hook(exc_type, exc_value, exc_traceback): logging.getLogger(__name__).error( @@ -19,40 +18,25 @@ def exception_hook(exc_type, exc_value, exc_traceback): sys.excepthook = exception_hook - if __name__ == '__main__': # Setting up root logger logger = logging.getLogger() - logger.setLevel(logging.DEBUG) - parent_parser = argparse.ArgumentParser(add_help=False) - parent_parser.add_argument("--debug", "-d", action='store_true') - arg_parser = argparse.ArgumentParser() + subparsers = arg_parser.add_subparsers(dest='command') - subparsers = arg_parser.add_subparsers(dest="command") + parent_parser = argparse.ArgumentParser(add_help=False) + parent_parser.add_argument('--debug', '-d', action='store_true', default=False, + help='More verbose logging') - api_parser = subparsers.add_parser("api", parents=[parent_parser]) - api_parser.add_argument("--port", "-p") + for component in ['api', 'scheduler', 'host', 'filescanner', 'imagescanner', + 'metadata', 'configure', 'cli']: + mod = importlib.import_module('uncloud.{}.main'.format(component)) + parser = getattr(mod, 'arg_parser') + subparsers.add_parser(name=parser.prog, parents=[parser, parent_parser]) - host_parser = subparsers.add_parser("host") - host_parser.add_argument("--hostname", required=True) - - scheduler_parser = subparsers.add_parser("scheduler", parents=[parent_parser]) - - - filescanner_parser = subparsers.add_parser("filescanner") - imagescanner_parser = subparsers.add_parser("imagescanner") - - metadata_parser = subparsers.add_parser("metadata") - metadata_parser.add_argument("--port", "-p") - - config_parser = subparsers.add_parser("configure") - - configure_parser(config_parser) args = arg_parser.parse_args() - if not args.command: arg_parser.print_help() else: @@ -62,12 +46,11 @@ if __name__ == '__main__': # errors out, so the following command configure multiprocessing # module to not inherit anything from parent. mp.set_start_method('spawn') - arguments = vars(args) try: name = arguments.pop('command') - mod = importlib.import_module("uncloud.{}.main".format(name)) - main = getattr(mod, "main") + mod = importlib.import_module('uncloud.{}.main'.format(name)) + main = getattr(mod, 'main') main(**arguments) except UncloudException as err: logger.error(err) diff --git a/setup.py b/setup.py index 0764d74..12da6b8 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ setup( "pynetbox", "colorama", "etcd3 @ https://github.com/kragniz/python-etcd3/tarball/master#egg=etcd3", - "marshmallow", + "marshmallow" ], scripts=["scripts/uncloud"], data_files=[ diff --git a/uncloud/api/main.py b/uncloud/api/main.py index 1cb736f..47e7003 100644 --- a/uncloud/api/main.py +++ b/uncloud/api/main.py @@ -1,6 +1,7 @@ import json import pynetbox import logging +import argparse from uuid import uuid4 from os.path import join as join_path @@ -14,7 +15,6 @@ from uncloud.common.vm import VMStatus from uncloud.common.request import RequestEntry, RequestType from uncloud.settings import settings from uncloud.shared import shared - from . import schemas from .helper import generate_mac, mac2ipv6 from uncloud import UncloudException @@ -25,6 +25,9 @@ app = Flask(__name__) api = Api(app) app.logger.handlers.clear() +arg_parser = argparse.ArgumentParser('api', add_help=False) +arg_parser.add_argument('--port', '-p') + @app.errorhandler(Exception) def handle_exception(e): @@ -34,11 +37,11 @@ def handle_exception(e): return e # now you're handling non-HTTP exceptions only - return {"message": "Server Error"}, 500 + return {'message': 'Server Error'}, 500 class CreateVM(Resource): - """API Request to Handle Creation of VM""" + '''API Request to Handle Creation of VM''' @staticmethod def post(): @@ -46,33 +49,33 @@ class CreateVM(Resource): validator = schemas.CreateVMSchema(data) if validator.is_valid(): vm_uuid = uuid4().hex - vm_key = join_path(settings["etcd"]["vm_prefix"], vm_uuid) + vm_key = join_path(settings['etcd']['vm_prefix'], vm_uuid) specs = { - "cpu": validator.specs["cpu"], - "ram": validator.specs["ram"], - "os-ssd": validator.specs["os-ssd"], - "hdd": validator.specs["hdd"], + 'cpu': validator.specs['cpu'], + 'ram': validator.specs['ram'], + 'os-ssd': validator.specs['os-ssd'], + 'hdd': validator.specs['hdd'], } - macs = [generate_mac() for _ in range(len(data["network"]))] + macs = [generate_mac() for _ in range(len(data['network']))] tap_ids = [ counters.increment_etcd_counter( - shared.etcd_client, "/v1/counter/tap" + shared.etcd_client, '/v1/counter/tap' ) - for _ in range(len(data["network"])) + for _ in range(len(data['network'])) ] vm_entry = { - "name": data["vm_name"], - "owner": data["name"], - "owner_realm": data["realm"], - "specs": specs, - "hostname": "", - "status": VMStatus.stopped, - "image_uuid": validator.image_uuid, - "log": [], - "vnc_socket": "", - "network": list(zip(data["network"], macs, tap_ids)), - "metadata": {"ssh-keys": []}, - "in_migration": False, + 'name': data['vm_name'], + 'owner': data['name'], + 'owner_realm': data['realm'], + 'specs': specs, + 'hostname': '', + 'status': VMStatus.stopped, + 'image_uuid': validator.image_uuid, + 'log': [], + 'vnc_socket': '', + 'network': list(zip(data['network'], macs, tap_ids)), + 'metadata': {'ssh-keys': []}, + 'in_migration': False, } shared.etcd_client.put(vm_key, vm_entry, value_in_json=True) @@ -80,11 +83,11 @@ class CreateVM(Resource): r = RequestEntry.from_scratch( type=RequestType.ScheduleVM, uuid=vm_uuid, - request_prefix=settings["etcd"]["request_prefix"], + request_prefix=settings['etcd']['request_prefix'], ) shared.request_pool.put(r) - return {"message": "VM Creation Queued"}, 200 + return {'message': 'VM Creation Queued'}, 200 return validator.get_errors(), 400 @@ -95,24 +98,24 @@ class VmStatus(Resource): validator = schemas.VMStatusSchema(data) if validator.is_valid(): vm = shared.vm_pool.get( - join_path(settings["etcd"]["vm_prefix"], data["uuid"]) + join_path(settings['etcd']['vm_prefix'], data['uuid']) ) vm_value = vm.value.copy() - vm_value["ip"] = [] + vm_value['ip'] = [] for network_mac_and_tap in vm.network: network_name, mac, tap = network_mac_and_tap network = shared.etcd_client.get( join_path( - settings["etcd"]["network_prefix"], - data["name"], + settings['etcd']['network_prefix'], + data['name'], network_name, ), value_in_json=True, ) ipv6_addr = ( - network.value.get("ipv6").split("::")[0] + "::" + network.value.get('ipv6').split('::')[0] + '::' ) - vm_value["ip"].append(mac2ipv6(mac, ipv6_addr)) + vm_value['ip'].append(mac2ipv6(mac, ipv6_addr)) vm.value = vm_value return vm.value else: @@ -126,26 +129,26 @@ class CreateImage(Resource): validator = schemas.CreateImageSchema(data) if validator.is_valid(): file_entry = shared.etcd_client.get( - join_path(settings["etcd"]["file_prefix"], data["uuid"]) + join_path(settings['etcd']['file_prefix'], data['uuid']) ) file_entry_value = json.loads(file_entry.value) image_entry_json = { - "status": "TO_BE_CREATED", - "owner": file_entry_value["owner"], - "filename": file_entry_value["filename"], - "name": data["name"], - "store_name": data["image_store"], - "visibility": "public", + 'status': 'TO_BE_CREATED', + 'owner': file_entry_value['owner'], + 'filename': file_entry_value['filename'], + 'name': data['name'], + 'store_name': data['image_store'], + 'visibility': 'public', } shared.etcd_client.put( join_path( - settings["etcd"]["image_prefix"], data["uuid"] + settings['etcd']['image_prefix'], data['uuid'] ), json.dumps(image_entry_json), ) - return {"message": "Image queued for creation."} + return {'message': 'Image queued for creation.'} return validator.get_errors(), 400 @@ -153,15 +156,15 @@ class ListPublicImages(Resource): @staticmethod def get(): images = shared.etcd_client.get_prefix( - settings["etcd"]["image_prefix"], value_in_json=True + settings['etcd']['image_prefix'], value_in_json=True ) - r = {"images": []} + r = {'images': []} for image in images: - image_key = "{}:{}".format( - image.value["store_name"], image.value["name"] + image_key = '{}:{}'.format( + image.value['store_name'], image.value['name'] ) - r["images"].append( - {"name": image_key, "status": image.value["status"]} + r['images'].append( + {'name': image_key, 'status': image.value['status']} ) return r, 200 @@ -174,14 +177,14 @@ class VMAction(Resource): if validator.is_valid(): vm_entry = shared.vm_pool.get( - join_path(settings["etcd"]["vm_prefix"], data["uuid"]) + join_path(settings['etcd']['vm_prefix'], data['uuid']) ) - action = data["action"] + action = data['action'] - if action == "start": - action = "schedule" + if action == 'start': + action = 'schedule' - if action == "delete" and vm_entry.hostname == "": + if action == 'delete' and vm_entry.hostname == '': if shared.storage_handler.is_vm_image_exists( vm_entry.uuid ): @@ -190,25 +193,25 @@ class VMAction(Resource): ) if r_status: shared.etcd_client.client.delete(vm_entry.key) - return {"message": "VM successfully deleted"} + return {'message': 'VM successfully deleted'} else: logger.error( - "Some Error Occurred while deleting VM" + 'Some Error Occurred while deleting VM' ) - return {"message": "VM deletion unsuccessfull"} + return {'message': 'VM deletion unsuccessfull'} else: shared.etcd_client.client.delete(vm_entry.key) - return {"message": "VM successfully deleted"} + return {'message': 'VM successfully deleted'} r = RequestEntry.from_scratch( - type="{}VM".format(action.title()), - uuid=data["uuid"], + type='{}VM'.format(action.title()), + uuid=data['uuid'], hostname=vm_entry.hostname, - request_prefix=settings["etcd"]["request_prefix"], + request_prefix=settings['etcd']['request_prefix'], ) shared.request_pool.put(r) return ( - {"message": "VM {} Queued".format(action.title())}, + {'message': 'VM {} Queued'.format(action.title())}, 200, ) else: @@ -222,20 +225,20 @@ class VMMigration(Resource): validator = schemas.VmMigrationSchema(data) if validator.is_valid(): - vm = shared.vm_pool.get(data["uuid"]) + vm = shared.vm_pool.get(data['uuid']) r = RequestEntry.from_scratch( type=RequestType.InitVMMigration, uuid=vm.uuid, hostname=join_path( - settings["etcd"]["host_prefix"], + settings['etcd']['host_prefix'], validator.destination.value, ), - request_prefix=settings["etcd"]["request_prefix"], + request_prefix=settings['etcd']['request_prefix'], ) shared.request_pool.put(r) return ( - {"message": "VM Migration Initialization Queued"}, + {'message': 'VM Migration Initialization Queued'}, 200, ) else: @@ -250,26 +253,26 @@ class ListUserVM(Resource): if validator.is_valid(): vms = shared.etcd_client.get_prefix( - settings["etcd"]["vm_prefix"], value_in_json=True + settings['etcd']['vm_prefix'], value_in_json=True ) return_vms = [] user_vms = filter( - lambda v: v.value["owner"] == data["name"], vms + lambda v: v.value['owner'] == data['name'], vms ) for vm in user_vms: return_vms.append( { - "name": vm.value["name"], - "vm_uuid": vm.key.split("/")[-1], - "specs": vm.value["specs"], - "status": vm.value["status"], - "hostname": vm.value["hostname"], - "vnc_socket": vm.value.get("vnc_socket", None), + 'name': vm.value['name'], + 'vm_uuid': vm.key.split('/')[-1], + 'specs': vm.value['specs'], + 'status': vm.value['status'], + 'hostname': vm.value['hostname'], + 'vnc_socket': vm.value.get('vnc_socket', None), } ) if return_vms: - return {"message": return_vms}, 200 - return {"message": "No VM found"}, 404 + return {'message': return_vms}, 200 + return {'message': 'No VM found'}, 404 else: return validator.get_errors(), 400 @@ -283,22 +286,22 @@ class ListUserFiles(Resource): if validator.is_valid(): files = shared.etcd_client.get_prefix( - settings["etcd"]["file_prefix"], value_in_json=True + settings['etcd']['file_prefix'], value_in_json=True ) return_files = [] user_files = list( filter( - lambda f: f.value["owner"] == data["name"], files + lambda f: f.value['owner'] == data['name'], files ) ) for file in user_files: return_files.append( { - "filename": file.value["filename"], - "uuid": file.key.split("/")[-1], + 'filename': file.value['filename'], + 'uuid': file.key.split('/')[-1], } ) - return {"message": return_files}, 200 + return {'message': return_files}, 200 else: return validator.get_errors(), 400 @@ -310,19 +313,19 @@ class CreateHost(Resource): validator = schemas.CreateHostSchema(data) if validator.is_valid(): host_key = join_path( - settings["etcd"]["host_prefix"], uuid4().hex + settings['etcd']['host_prefix'], uuid4().hex ) host_entry = { - "specs": data["specs"], - "hostname": data["hostname"], - "status": "DEAD", - "last_heartbeat": "", + 'specs': data['specs'], + 'hostname': data['hostname'], + 'status': 'DEAD', + 'last_heartbeat': '', } shared.etcd_client.put( host_key, host_entry, value_in_json=True ) - return {"message": "Host Created"}, 200 + return {'message': 'Host Created'}, 200 return validator.get_errors(), 400 @@ -333,9 +336,9 @@ class ListHost(Resource): hosts = shared.host_pool.hosts r = { host.key: { - "status": host.status, - "specs": host.specs, - "hostname": host.hostname, + 'status': host.status, + 'specs': host.specs, + 'hostname': host.hostname, } for host in hosts } @@ -352,29 +355,29 @@ class GetSSHKeys(Resource): # {user_prefix}/{realm}/{name}/key/ etcd_key = join_path( - settings["etcd"]["user_prefix"], - data["realm"], - data["name"], - "key", + settings['etcd']['user_prefix'], + data['realm'], + data['name'], + 'key', ) etcd_entry = shared.etcd_client.get_prefix( etcd_key, value_in_json=True ) keys = { - key.key.split("/")[-1]: key.value + key.key.split('/')[-1]: key.value for key in etcd_entry } - return {"keys": keys} + return {'keys': keys} else: # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - settings["etcd"]["user_prefix"], - data["realm"], - data["name"], - "key", - data["key_name"], + settings['etcd']['user_prefix'], + data['realm'], + data['name'], + 'key', + data['key_name'], ) etcd_entry = shared.etcd_client.get( etcd_key, value_in_json=True @@ -382,14 +385,14 @@ class GetSSHKeys(Resource): if etcd_entry: return { - "keys": { - etcd_entry.key.split("/")[ + 'keys': { + etcd_entry.key.split('/')[ -1 ]: etcd_entry.value } } else: - return {"keys": {}} + return {'keys': {}} else: return validator.get_errors(), 400 @@ -403,27 +406,27 @@ class AddSSHKey(Resource): # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - settings["etcd"]["user_prefix"], - data["realm"], - data["name"], - "key", - data["key_name"], + settings['etcd']['user_prefix'], + data['realm'], + data['name'], + 'key', + data['key_name'], ) etcd_entry = shared.etcd_client.get( etcd_key, value_in_json=True ) if etcd_entry: return { - "message": "Key with name '{}' already exists".format( - data["key_name"] + 'message': 'Key with name "{}" already exists'.format( + data['key_name'] ) } else: # Key Not Found. It implies user' haven't added any key yet. shared.etcd_client.put( - etcd_key, data["key"], value_in_json=True + etcd_key, data['key'], value_in_json=True ) - return {"message": "Key added successfully"} + return {'message': 'Key added successfully'} else: return validator.get_errors(), 400 @@ -437,22 +440,22 @@ class RemoveSSHKey(Resource): # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - settings["etcd"]["user_prefix"], - data["realm"], - data["name"], - "key", - data["key_name"], + settings['etcd']['user_prefix'], + data['realm'], + data['name'], + 'key', + data['key_name'], ) etcd_entry = shared.etcd_client.get( etcd_key, value_in_json=True ) if etcd_entry: shared.etcd_client.client.delete(etcd_key) - return {"message": "Key successfully removed."} + return {'message': 'Key successfully removed.'} else: return { - "message": "No Key with name '{}' Exists at all.".format( - data["key_name"] + 'message': 'No Key with name "{}" Exists at all.'.format( + data['key_name'] ) } else: @@ -468,50 +471,50 @@ class CreateNetwork(Resource): if validator.is_valid(): network_entry = { - "id": counters.increment_etcd_counter( - shared.etcd_client, "/v1/counter/vxlan" + 'id': counters.increment_etcd_counter( + shared.etcd_client, '/v1/counter/vxlan' ), - "type": data["type"], + 'type': data['type'], } if validator.user.value: try: nb = pynetbox.api( - url=settings["netbox"]["url"], - token=settings["netbox"]["token"], + url=settings['netbox']['url'], + token=settings['netbox']['token'], ) nb_prefix = nb.ipam.prefixes.get( - prefix=settings["network"]["prefix"] + prefix=settings['network']['prefix'] ) prefix = nb_prefix.available_prefixes.create( data={ - "prefix_length": int( - settings["network"]["prefix_length"] + 'prefix_length': int( + settings['network']['prefix_length'] ), - "description": '{}\'s network "{}"'.format( - data["name"], data["network_name"] + 'description': '{}\'s network "{}"'.format( + data['name'], data['network_name'] ), - "is_pool": True, + 'is_pool': True, } ) except Exception as err: app.logger.error(err) return { - "message": "Error occured while creating network." + 'message': 'Error occured while creating network.' } else: - network_entry["ipv6"] = prefix["prefix"] + network_entry['ipv6'] = prefix['prefix'] else: - network_entry["ipv6"] = "fd00::/64" + network_entry['ipv6'] = 'fd00::/64' network_key = join_path( - settings["etcd"]["network_prefix"], - data["name"], - data["network_name"], + settings['etcd']['network_prefix'], + data['name'], + data['network_name'], ) shared.etcd_client.put( network_key, network_entry, value_in_json=True ) - return {"message": "Network successfully added."} + return {'message': 'Network successfully added.'} else: return validator.get_errors(), 400 @@ -524,48 +527,48 @@ class ListUserNetwork(Resource): if validator.is_valid(): prefix = join_path( - settings["etcd"]["network_prefix"], data["name"] + settings['etcd']['network_prefix'], data['name'] ) networks = shared.etcd_client.get_prefix( prefix, value_in_json=True ) user_networks = [] for net in networks: - net.value["name"] = net.key.split("/")[-1] + net.value['name'] = net.key.split('/')[-1] user_networks.append(net.value) - return {"networks": user_networks}, 200 + return {'networks': user_networks}, 200 else: return validator.get_errors(), 400 -api.add_resource(CreateVM, "/vm/create") -api.add_resource(VmStatus, "/vm/status") +api.add_resource(CreateVM, '/vm/create') +api.add_resource(VmStatus, '/vm/status') -api.add_resource(VMAction, "/vm/action") -api.add_resource(VMMigration, "/vm/migrate") +api.add_resource(VMAction, '/vm/action') +api.add_resource(VMMigration, '/vm/migrate') -api.add_resource(CreateImage, "/image/create") -api.add_resource(ListPublicImages, "/image/list-public") +api.add_resource(CreateImage, '/image/create') +api.add_resource(ListPublicImages, '/image/list-public') -api.add_resource(ListUserVM, "/user/vms") -api.add_resource(ListUserFiles, "/user/files") -api.add_resource(ListUserNetwork, "/user/networks") +api.add_resource(ListUserVM, '/user/vms') +api.add_resource(ListUserFiles, '/user/files') +api.add_resource(ListUserNetwork, '/user/networks') -api.add_resource(AddSSHKey, "/user/add-ssh") -api.add_resource(RemoveSSHKey, "/user/remove-ssh") -api.add_resource(GetSSHKeys, "/user/get-ssh") +api.add_resource(AddSSHKey, '/user/add-ssh') +api.add_resource(RemoveSSHKey, '/user/remove-ssh') +api.add_resource(GetSSHKeys, '/user/get-ssh') -api.add_resource(CreateHost, "/host/create") -api.add_resource(ListHost, "/host/list") +api.add_resource(CreateHost, '/host/create') +api.add_resource(ListHost, '/host/list') -api.add_resource(CreateNetwork, "/network/create") +api.add_resource(CreateNetwork, '/network/create') def main(debug=False, port=None): try: image_stores = list( shared.etcd_client.get_prefix( - settings["etcd"]["image_store_prefix"], value_in_json=True + settings['etcd']['image_store_prefix'], value_in_json=True ) ) except KeyError: @@ -576,27 +579,27 @@ def main(debug=False, port=None): # # if not image_stores: # data = { - # "is_public": True, - # "type": "ceph", - # "name": "images", - # "description": "first ever public image-store", - # "attributes": {"list": [], "key": [], "pool": "images"}, + # 'is_public': True, + # 'type': 'ceph', + # 'name': 'images', + # 'description': 'first ever public image-store', + # 'attributes': {'list': [], 'key': [], 'pool': 'images'}, # } # shared.etcd_client.put( # join_path( - # settings["etcd"]["image_store_prefix"], uuid4().hex + # settings['etcd']['image_store_prefix'], uuid4().hex # ), # json.dumps(data), # ) try: - app.run(host="::", + app.run(host='::', port=port, debug=debug) except OSError as e: - raise UncloudException("Failed to start Flask: {}".format(e)) + raise UncloudException('Failed to start Flask: {}'.format(e)) -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/uncloud/api/schemas.py b/uncloud/api/schemas.py index 65055c4..8e06e8d 100755 --- a/uncloud/api/schemas.py +++ b/uncloud/api/schemas.py @@ -322,7 +322,7 @@ class CreateVMSchema(OTPSchema): "Your specified OS-SSD is not in correct units" ) - if _cpu < 1: + if int(_cpu) < 1: self.add_error("CPU must be atleast 1") if parsed_ram < bitmath.GB(1): @@ -528,9 +528,7 @@ class GetSSHSchema(OTPSchema): class CreateNetwork(OTPSchema): def __init__(self, data): - self.network_name = Field( - "network_name", str, data.get("network_name", KeyError) - ) + self.network_name = Field("network_name", str, data.get("network_name", KeyError)) self.type = Field("type", str, data.get("type", KeyError)) self.user = Field("user", bool, bool(data.get("user", False))) @@ -541,14 +539,10 @@ class CreateNetwork(OTPSchema): super().__init__(data, fields=fields) def network_name_validation(self): - network = shared.etcd_client.get( - os.path.join( - settings["etcd"]["network_prefix"], - self.name.value, - self.network_name.value, - ), - value_in_json=True, - ) + print(self.name.value, self.network_name.value) + key = os.path.join(settings["etcd"]["network_prefix"], self.name.value, self.network_name.value) + print(key) + network = shared.etcd_client.get(key, value_in_json=True) if network: self.add_error( "Network with name {} already exists".format( diff --git a/uncloud/common/parser.py b/uncloud/common/parser.py index e69de29..576f0e7 100644 --- a/uncloud/common/parser.py +++ b/uncloud/common/parser.py @@ -0,0 +1,13 @@ +import argparse + + +class BaseParser: + def __init__(self, command): + self.arg_parser = argparse.ArgumentParser(command, add_help=False) + self.subparser = self.arg_parser.add_subparsers(dest='{}_subcommand'.format(command)) + self.common_args = {'add_help': False} + + methods = [attr for attr in dir(self) if not attr.startswith('__') + and type(getattr(self, attr)).__name__ == 'method'] + for method in methods: + getattr(self, method)(**self.common_args) diff --git a/uncloud/configure/main.py b/uncloud/configure/main.py index a9b4901..f89a30c 100644 --- a/uncloud/configure/main.py +++ b/uncloud/configure/main.py @@ -1,8 +1,43 @@ import os +import argparse from uncloud.settings import settings from uncloud.shared import shared +arg_parser = argparse.ArgumentParser('configure', add_help=False) +configure_subparsers = arg_parser.add_subparsers(dest='subcommand') + +otp_parser = configure_subparsers.add_parser('otp') +otp_parser.add_argument('--verification-controller-url', required=True, metavar='URL') +otp_parser.add_argument('--auth-name', required=True, metavar='OTP-NAME') +otp_parser.add_argument('--auth-realm', required=True, metavar='OTP-REALM') +otp_parser.add_argument('--auth-seed', required=True, metavar='OTP-SEED') + +network_parser = configure_subparsers.add_parser('network') +network_parser.add_argument('--prefix-length', required=True, type=int) +network_parser.add_argument('--prefix', required=True) +network_parser.add_argument('--vxlan-phy-dev', required=True) + +netbox_parser = configure_subparsers.add_parser('netbox') +netbox_parser.add_argument('--url', required=True) +netbox_parser.add_argument('--token', required=True) + +ssh_parser = configure_subparsers.add_parser('ssh') +ssh_parser.add_argument('--username', default='root') +ssh_parser.add_argument('--private-key-path', default=os.path.expanduser('~/.ssh/id_rsa'),) + +storage_parser = configure_subparsers.add_parser('storage') +storage_parser.add_argument('--file-dir', required=True) +storage_parser_subparsers = storage_parser.add_subparsers(dest='storage_backend') + +filesystem_storage_parser = storage_parser_subparsers.add_parser('filesystem') +filesystem_storage_parser.add_argument('--vm-dir', required=True) +filesystem_storage_parser.add_argument('--image-dir', required=True) + +ceph_storage_parser = storage_parser_subparsers.add_parser('ceph') +ceph_storage_parser.add_argument('--ceph-vm-pool', required=True) +ceph_storage_parser.add_argument('--ceph-image-pool', required=True) + def update_config(section, kwargs): uncloud_config = shared.etcd_client.get( @@ -19,61 +54,9 @@ def update_config(section, kwargs): ) -def configure_parser(parser): - configure_subparsers = parser.add_subparsers(dest="subcommand") - - otp_parser = configure_subparsers.add_parser("otp") - otp_parser.add_argument( - "--verification-controller-url", required=True, metavar="URL" - ) - otp_parser.add_argument( - "--auth-name", required=True, metavar="OTP-NAME" - ) - otp_parser.add_argument( - "--auth-realm", required=True, metavar="OTP-REALM" - ) - otp_parser.add_argument( - "--auth-seed", required=True, metavar="OTP-SEED" - ) - - network_parser = configure_subparsers.add_parser("network") - network_parser.add_argument( - "--prefix-length", required=True, type=int - ) - network_parser.add_argument("--prefix", required=True) - network_parser.add_argument("--vxlan-phy-dev", required=True) - - netbox_parser = configure_subparsers.add_parser("netbox") - netbox_parser.add_argument("--url", required=True) - netbox_parser.add_argument("--token", required=True) - - ssh_parser = configure_subparsers.add_parser("ssh") - ssh_parser.add_argument("--username", default="root") - ssh_parser.add_argument( - "--private-key-path", - default=os.path.expanduser("~/.ssh/id_rsa"), - ) - - storage_parser = configure_subparsers.add_parser("storage") - storage_parser.add_argument("--file-dir", required=True) - storage_parser_subparsers = storage_parser.add_subparsers( - dest="storage_backend" - ) - - filesystem_storage_parser = storage_parser_subparsers.add_parser( - "filesystem" - ) - filesystem_storage_parser.add_argument("--vm-dir", required=True) - filesystem_storage_parser.add_argument("--image-dir", required=True) - - ceph_storage_parser = storage_parser_subparsers.add_parser("ceph") - ceph_storage_parser.add_argument("--ceph-vm-pool", required=True) - ceph_storage_parser.add_argument("--ceph-image-pool", required=True) - - def main(**kwargs): - subcommand = kwargs.pop("subcommand") + subcommand = kwargs.pop('subcommand') if not subcommand: - pass + arg_parser.print_help() else: update_config(subcommand, kwargs) diff --git a/uncloud/filescanner/main.py b/uncloud/filescanner/main.py index bb318c3..c81fbbe 100755 --- a/uncloud/filescanner/main.py +++ b/uncloud/filescanner/main.py @@ -3,6 +3,7 @@ import os import pathlib import subprocess as sp import time +import argparse from uuid import uuid4 @@ -11,6 +12,9 @@ from uncloud.settings import settings from uncloud.shared import shared +arg_parser = argparse.ArgumentParser('filescanner', add_help=False) + + def sha512sum(file: str): """Use sha512sum utility to compute sha512 sum of arg:file diff --git a/uncloud/host/main.py b/uncloud/host/main.py index e469725..ec2ef4d 100755 --- a/uncloud/host/main.py +++ b/uncloud/host/main.py @@ -1,6 +1,7 @@ import argparse import multiprocessing as mp import time + from uuid import uuid4 from uncloud.common.request import RequestEntry, RequestType @@ -12,6 +13,9 @@ from os.path import join as join_path from . import virtualmachine, logger +arg_parser = argparse.ArgumentParser('host', add_help=False) +arg_parser.add_argument('--hostname', required=True) + def update_heartbeat(hostname): """Update Last HeartBeat Time for :param hostname: in etcd""" diff --git a/uncloud/imagescanner/main.py b/uncloud/imagescanner/main.py index fc77809..a43a36c 100755 --- a/uncloud/imagescanner/main.py +++ b/uncloud/imagescanner/main.py @@ -1,5 +1,6 @@ import json import os +import argparse import subprocess as sp from os.path import join as join_path @@ -8,6 +9,9 @@ from uncloud.shared import shared from uncloud.imagescanner import logger +arg_parser = argparse.ArgumentParser('imagescanner', add_help=False) + + def qemu_img_type(path): qemu_img_info_command = [ "qemu-img", diff --git a/uncloud/metadata/main.py b/uncloud/metadata/main.py index a59a998..e2199b8 100644 --- a/uncloud/metadata/main.py +++ b/uncloud/metadata/main.py @@ -1,4 +1,5 @@ import os +import argparse from flask import Flask, request from flask_restful import Resource, Api @@ -12,6 +13,9 @@ api = Api(app) app.logger.handlers.clear() +arg_parser = argparse.ArgumentParser('metadata', add_help=False) +arg_parser.add_argument('--port', '-p', default=80, help='By default bind to port 80') + @app.errorhandler(Exception) def handle_exception(e): diff --git a/uncloud/scheduler/main.py b/uncloud/scheduler/main.py index 79b1edc..1ef6226 100755 --- a/uncloud/scheduler/main.py +++ b/uncloud/scheduler/main.py @@ -4,17 +4,16 @@ # 2. Introduce a status endpoint of the scheduler - # maybe expose a prometheus compatible output +import argparse + from uncloud.common.request import RequestEntry, RequestType from uncloud.shared import shared from uncloud.settings import settings -from .helper import ( - dead_host_mitigation, - dead_host_detection, - assign_host, - NoSuitableHostFound, -) +from .helper import (dead_host_mitigation, dead_host_detection, assign_host, NoSuitableHostFound) from . import logger +arg_parser = argparse.ArgumentParser('scheduler', add_help=False) + def main(debug=False): for request_iterator in [ diff --git a/uncloud/settings/__init__.py b/uncloud/settings/__init__.py index 629660e..f6da61c 100644 --- a/uncloud/settings/__init__.py +++ b/uncloud/settings/__init__.py @@ -3,6 +3,8 @@ import logging import sys import os +from datetime import datetime + from uncloud.common.etcd_wrapper import Etcd3Wrapper logger = logging.getLogger(__name__) @@ -29,10 +31,13 @@ class Settings(object): "UCLOUD_CONF_DIR", os.path.expanduser("~/uncloud/") ) self.config_file = os.path.join(conf_dir, conf_name) - self.config_parser = CustomConfigParser(allow_no_value=True) self.config_key = config_key + # this is used to cache config from etcd for 1 minutes. Without this we + # would make a lot of requests to etcd which slows down everything. + self.last_config_update = datetime.fromtimestamp(0) + self.read_internal_values() try: self.config_parser.read(self.config_file) @@ -102,25 +107,22 @@ class Settings(object): def read_values_from_etcd(self): etcd_client = self.get_etcd_client() - config_from_etcd = etcd_client.get( - self.config_key, value_in_json=True - ) - if config_from_etcd: - self.config_parser.read_dict(config_from_etcd.value) - else: - raise KeyError( - "Key '{}' not found in etcd. Please configure uncloud.".format( - self.config_key - ) - ) + if (datetime.utcnow() - self.last_config_update).total_seconds() > 60: + config_from_etcd = etcd_client.get(self.config_key, value_in_json=True) + if config_from_etcd: + self.config_parser.read_dict(config_from_etcd.value) + self.last_config_update = datetime.utcnow() + else: + raise KeyError("Key '{}' not found in etcd. Please configure uncloud.".format(self.config_key)) def __getitem__(self, key): # Allow failing to read from etcd if we have # it locally - try: - self.read_values_from_etcd() - except KeyError as e: - pass + if key not in self.config_parser.sections(): + try: + self.read_values_from_etcd() + except KeyError as e: + pass return self.config_parser[key] From 344a957a3fe4bcc1f93b048672ce90ff204daf31 Mon Sep 17 00:00:00 2001 From: meow Date: Fri, 3 Jan 2020 18:42:20 +0500 Subject: [PATCH 116/284] Removed duplicate add_help from argument parsers in cli/image and cli/network --- uncloud/cli/image.py | 2 +- uncloud/cli/network.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/uncloud/cli/image.py b/uncloud/cli/image.py index 3db9577..641a00f 100644 --- a/uncloud/cli/image.py +++ b/uncloud/cli/image.py @@ -15,7 +15,7 @@ class ImageParser(BaseParser): p.add_argument('--image-store-name', default='image_store') def list(self, **kwargs): - self.subparser.add_parser('list', add_help=False, **kwargs) + self.subparser.add_parser('list', **kwargs) parser = ImageParser() diff --git a/uncloud/cli/network.py b/uncloud/cli/network.py index 33e41a9..55798bf 100644 --- a/uncloud/cli/network.py +++ b/uncloud/cli/network.py @@ -9,7 +9,7 @@ class NetworkParser(BaseParser): super().__init__('network') def create(self, **kwargs): - p = self.subparser.add_parser('create', add_help=False, parents=[get_otp_parser()], **kwargs) + p = self.subparser.add_parser('create', parents=[get_otp_parser()], **kwargs) p.add_argument('--network-name', required=True) p.add_argument('--network-type', required=True, dest='type') p.add_argument('--user', action='store_true') From 180f6f4989133b4a27833d6f9eebc7cc26cf02ce Mon Sep 17 00:00:00 2001 From: meow Date: Sun, 5 Jan 2020 17:21:26 +0500 Subject: [PATCH 117/284] No longer using xattrs as they don't work on tmpfs/rootfs --- uncloud/filescanner/main.py | 78 +++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 42 deletions(-) diff --git a/uncloud/filescanner/main.py b/uncloud/filescanner/main.py index c81fbbe..e4d807c 100755 --- a/uncloud/filescanner/main.py +++ b/uncloud/filescanner/main.py @@ -4,6 +4,7 @@ import pathlib import subprocess as sp import time import argparse +import bitmath from uuid import uuid4 @@ -28,66 +29,59 @@ def sha512sum(file: str): if not isinstance(file, str): raise TypeError try: - output = sp.check_output(["sha512sum", file], stderr=sp.PIPE) + output = sp.check_output(['sha512sum', file], stderr=sp.PIPE) except sp.CalledProcessError as e: - error = e.stderr.decode("utf-8") - if "No such file or directory" in error: + error = e.stderr.decode('utf-8') + if 'No such file or directory' in error: raise FileNotFoundError from None else: - output = output.decode("utf-8").strip() - output = output.split(" ") + output = output.decode('utf-8').strip() + output = output.split(' ') return output[0] return None def track_file(file, base_dir): - file_id = uuid4() + file_path = file.relative_to(base_dir) # Get Username - owner = pathlib.Path(file).parts[len(pathlib.Path(base_dir).parts)] + try: + owner = file_path.parts[0] + except IndexError: + pass + else: + file_path = file_path.relative_to(owner) + creation_date = time.ctime(os.stat(file).st_ctime) - # Get Creation Date of File - # Here, we are assuming that ctime is creation time - # which is mostly not true. - creation_date = time.ctime(os.stat(file).st_ctime) + entry_key = os.path.join(settings['etcd']['file_prefix'], str(uuid4())) + entry_value = { + 'filename': str(file_path), + 'owner': owner, + 'sha512sum': sha512sum(str(file)), + 'creation_date': creation_date, + 'size': str(bitmath.Byte(os.path.getsize(str(file))).to_MB()), + } - file_path = pathlib.Path(file).parts[-1] + logger.info('Tracking %s', file) - # Create Entry - entry_key = os.path.join( - settings["etcd"]["file_prefix"], str(file_id) - ) - entry_value = { - "filename": file_path, - "owner": owner, - "sha512sum": sha512sum(file), - "creation_date": creation_date, - "size": os.path.getsize(file), - } - - logger.info("Tracking %s", file) - - shared.etcd_client.put(entry_key, entry_value, value_in_json=True) - os.setxattr(file, "user.utracked", b"True") + shared.etcd_client.put(entry_key, entry_value, value_in_json=True) def main(debug=False): - base_dir = settings["storage"]["file_dir"] - + base_dir = pathlib.Path(settings['storage']['file_dir']) # Recursively Get All Files and Folder below BASE_DIR - files = glob.glob("{}/**".format(base_dir), recursive=True) + files = glob.glob('{}/**'.format(base_dir), recursive=True) + files = [pathlib.Path(f) for f in files if pathlib.Path(f).is_file()] - # Retain only Files - files = [file for file in files if os.path.isfile(file)] - - untracked_files = [] - for file in files: - try: - os.getxattr(file, "user.utracked") - except OSError: - track_file(file, base_dir) - untracked_files.append(file) + # Files that are already tracked + tracked_files = [ + pathlib.Path(os.path.join(base_dir, f.value['owner'], f.value['filename'])) + for f in shared.etcd_client.get_prefix(settings['etcd']['file_prefix'], value_in_json=True) + ] + untracked_files = set(files) - set(tracked_files) + for file in untracked_files: + track_file(file, base_dir) -if __name__ == "__main__": +if __name__ == '__main__': main() From 6847a0d3232c2725852397f0e22dd8edcf7bfddd Mon Sep 17 00:00:00 2001 From: meow Date: Sun, 5 Jan 2020 17:56:42 +0500 Subject: [PATCH 118/284] base dir reverted back to str path --- uncloud/filescanner/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud/filescanner/main.py b/uncloud/filescanner/main.py index e4d807c..89d32d4 100755 --- a/uncloud/filescanner/main.py +++ b/uncloud/filescanner/main.py @@ -68,7 +68,7 @@ def track_file(file, base_dir): def main(debug=False): - base_dir = pathlib.Path(settings['storage']['file_dir']) + base_dir = settings['storage']['file_dir'] # Recursively Get All Files and Folder below BASE_DIR files = glob.glob('{}/**'.format(base_dir), recursive=True) files = [pathlib.Path(f) for f in files if pathlib.Path(f).is_file()] From 7fff280c7945e979f860e9383b481d037e9fd754 Mon Sep 17 00:00:00 2001 From: meow Date: Sun, 5 Jan 2020 18:00:05 +0500 Subject: [PATCH 119/284] uncloud filescanner os.path.getsize expects str given Path instead --- uncloud/filescanner/main.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/uncloud/filescanner/main.py b/uncloud/filescanner/main.py index 89d32d4..19c43ef 100755 --- a/uncloud/filescanner/main.py +++ b/uncloud/filescanner/main.py @@ -43,7 +43,7 @@ def sha512sum(file: str): def track_file(file, base_dir): file_path = file.relative_to(base_dir) - + file_str = str(file) # Get Username try: owner = file_path.parts[0] @@ -51,18 +51,18 @@ def track_file(file, base_dir): pass else: file_path = file_path.relative_to(owner) - creation_date = time.ctime(os.stat(file).st_ctime) + creation_date = time.ctime(os.stat(file_str).st_ctime) entry_key = os.path.join(settings['etcd']['file_prefix'], str(uuid4())) entry_value = { 'filename': str(file_path), 'owner': owner, - 'sha512sum': sha512sum(str(file)), + 'sha512sum': sha512sum(file_str), 'creation_date': creation_date, - 'size': str(bitmath.Byte(os.path.getsize(str(file))).to_MB()), + 'size': str(bitmath.Byte(os.path.getsize(file_str)).to_MB()), } - logger.info('Tracking %s', file) + logger.info('Tracking %s', file_str) shared.etcd_client.put(entry_key, entry_value, value_in_json=True) From 6f51ddbb3623e36dde7dc410cfe35fa7ea9adfbd Mon Sep 17 00:00:00 2001 From: meow Date: Sun, 5 Jan 2020 18:31:48 +0500 Subject: [PATCH 120/284] renamed argument, and changed destination and make it required (uncloud.cli.image.create_image_from_file) --- uncloud/api/create_image_store.py | 12 ++++++------ uncloud/cli/image.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/uncloud/api/create_image_store.py b/uncloud/api/create_image_store.py index 73b92f1..9259be6 100755 --- a/uncloud/api/create_image_store.py +++ b/uncloud/api/create_image_store.py @@ -7,14 +7,14 @@ from uncloud.shared import shared from uncloud.settings import settings data = { - "is_public": True, - "type": "ceph", - "name": "images", - "description": "first ever public image-store", - "attributes": {"list": [], "key": [], "pool": "images"}, + 'is_public': True, + 'type': 'ceph', + 'name': 'images', + 'description': 'first ever public image-store', + 'attributes': {'list': [], 'key': [], 'pool': 'images'}, } shared.etcd_client.put( - os.path.join(settings["etcd"]["image_store_prefix"], uuid4().hex), + os.path.join(settings['etcd']['image_store_prefix'], uuid4().hex), json.dumps(data), ) diff --git a/uncloud/cli/image.py b/uncloud/cli/image.py index 641a00f..2f59c32 100644 --- a/uncloud/cli/image.py +++ b/uncloud/cli/image.py @@ -12,7 +12,7 @@ class ImageParser(BaseParser): p = self.subparser.add_parser('create', **kwargs) p.add_argument('--name', required=True) p.add_argument('--uuid', required=True) - p.add_argument('--image-store-name', default='image_store') + p.add_argument('--image-store', required=True, dest='image_store') def list(self, **kwargs): self.subparser.add_parser('list', **kwargs) From b7f3ba1a3488d9212e500fade9b6186082626cf7 Mon Sep 17 00:00:00 2001 From: meow Date: Sun, 5 Jan 2020 19:46:38 +0500 Subject: [PATCH 121/284] remove cache=none from QEMU args as it is not supported on tmpfs/rootfs --- uncloud/api/main.py | 2 +- uncloud/host/virtualmachine.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/uncloud/api/main.py b/uncloud/api/main.py index 47e7003..84eb28a 100644 --- a/uncloud/api/main.py +++ b/uncloud/api/main.py @@ -41,7 +41,7 @@ def handle_exception(e): class CreateVM(Resource): - '''API Request to Handle Creation of VM''' + """API Request to Handle Creation of VM""" @staticmethod def post(): diff --git a/uncloud/host/virtualmachine.py b/uncloud/host/virtualmachine.py index 0bd20bf..cbb3bbe 100755 --- a/uncloud/host/virtualmachine.py +++ b/uncloud/host/virtualmachine.py @@ -42,7 +42,7 @@ class VM: def get_qemu_args(self): command = ( - "-drive file={file},format=raw,if=virtio,cache=none" + "-drive file={file},format=raw,if=virtio" " -device virtio-rng-pci" " -m {memory} -smp cores={cores},threads={threads}" " -name {owner}_{name}" From ec40d6b1e0b2c00ad36c9ba4666f7951e55bbbff Mon Sep 17 00:00:00 2001 From: meow Date: Sun, 5 Jan 2020 20:20:00 +0500 Subject: [PATCH 122/284] don't suppress error when changing permissions in uncloud vmm --- uncloud/host/main.py | 36 +++++++++++------------ uncloud/settings/__init__.py | 56 +++++++++++++++++------------------- uncloud/vmm/__init__.py | 16 +++-------- 3 files changed, 49 insertions(+), 59 deletions(-) diff --git a/uncloud/host/main.py b/uncloud/host/main.py index ec2ef4d..b7c8b1c 100755 --- a/uncloud/host/main.py +++ b/uncloud/host/main.py @@ -33,10 +33,10 @@ def maintenance(host): vmm = VMM() running_vms = vmm.discover() for vm_uuid in running_vms: - if vmm.is_running(vm_uuid) and vmm.get_status(vm_uuid) == "running": + if vmm.is_running(vm_uuid) and vmm.get_status(vm_uuid) == 'running': logger.debug('VM {} is running on {}'.format(vm_uuid, host)) vm = shared.vm_pool.get( - join_path(settings["etcd"]["vm_prefix"], vm_uuid) + join_path(settings['etcd']['vm_prefix'], vm_uuid) ) vm.status = VMStatus.running vm.vnc_socket = vmm.get_vnc(vm_uuid) @@ -51,13 +51,13 @@ def main(hostname, debug=False): # Does not yet exist, create it if not host: host_key = join_path( - settings["etcd"]["host_prefix"], uuid4().hex + settings['etcd']['host_prefix'], uuid4().hex ) host_entry = { - "specs": "", - "hostname": hostname, - "status": "DEAD", - "last_heartbeat": "", + 'specs': '', + 'hostname': hostname, + 'status': 'DEAD', + 'last_heartbeat': '', } shared.etcd_client.put( host_key, host_entry, value_in_json=True @@ -70,25 +70,25 @@ def main(hostname, debug=False): heartbeat_updating_process = mp.Process(target=update_heartbeat, args=(hostname,)) heartbeat_updating_process.start() except Exception as e: - raise Exception("uncloud-host heartbeat updating mechanism is not working") from e + raise Exception('uncloud-host heartbeat updating mechanism is not working') from e for events_iterator in [ - shared.etcd_client.get_prefix(settings["etcd"]["request_prefix"], value_in_json=True), - shared.etcd_client.watch_prefix(settings["etcd"]["request_prefix"], timeout=10, value_in_json=True) + shared.etcd_client.get_prefix(settings['etcd']['request_prefix'], value_in_json=True), + shared.etcd_client.watch_prefix(settings['etcd']['request_prefix'], timeout=10, value_in_json=True) ]: for request_event in events_iterator: request_event = RequestEntry(request_event) - if request_event.type == "TIMEOUT": + if request_event.type == 'TIMEOUT': maintenance(host.key) elif request_event.hostname == host.key: - logger.debug("VM Request: %s on Host %s", request_event, host.hostname) + logger.debug('VM Request: %s on Host %s', request_event, host.hostname) shared.request_pool.client.client.delete(request_event.key) vm_entry = shared.etcd_client.get( - join_path(settings["etcd"]["vm_prefix"], request_event.uuid) + join_path(settings['etcd']['vm_prefix'], request_event.uuid) ) - logger.debug("VM hostname: {}".format(vm_entry.value)) + logger.debug('VM hostname: {}'.format(vm_entry.value)) vm = virtualmachine.VM(vm_entry) if request_event.type == RequestType.StartVM: vm.start() @@ -110,14 +110,14 @@ def main(hostname, debug=False): destination_sock_path=request_event.destination_sock_path, ) else: - logger.error("Host %s not found!", request_event.destination_host_key) + logger.error('Host %s not found!', request_event.destination_host_key) -if __name__ == "__main__": +if __name__ == '__main__': argparser = argparse.ArgumentParser() argparser.add_argument( - "hostname", help="Name of this host. e.g uncloud1.ungleich.ch" + 'hostname', help='Name of this host. e.g uncloud1.ungleich.ch' ) args = argparser.parse_args() - mp.set_start_method("spawn") + mp.set_start_method('spawn') main(args.hostname) diff --git a/uncloud/settings/__init__.py b/uncloud/settings/__init__.py index f6da61c..0654b9b 100644 --- a/uncloud/settings/__init__.py +++ b/uncloud/settings/__init__.py @@ -16,7 +16,7 @@ class CustomConfigParser(configparser.RawConfigParser): result = super().__getitem__(key) except KeyError as err: raise KeyError( - "Key '{}' not found in configuration. Make sure you configure uncloud.".format( + 'Key \'{}\' not found in configuration. Make sure you configure uncloud.'.format( key ) ) from err @@ -25,10 +25,10 @@ class CustomConfigParser(configparser.RawConfigParser): class Settings(object): - def __init__(self, config_key="/uncloud/config/"): - conf_name = "uncloud.conf" + def __init__(self, config_key='/uncloud/config/'): + conf_name = 'uncloud.conf' conf_dir = os.environ.get( - "UCLOUD_CONF_DIR", os.path.expanduser("~/uncloud/") + 'UCLOUD_CONF_DIR', os.path.expanduser('~/uncloud/') ) self.config_file = os.path.join(conf_dir, conf_name) self.config_parser = CustomConfigParser(allow_no_value=True) @@ -42,23 +42,21 @@ class Settings(object): try: self.config_parser.read(self.config_file) except Exception as err: - logger.error("%s", err) + logger.error('%s', err) def get_etcd_client(self): args = tuple() try: kwargs = { - "host": self.config_parser.get("etcd", "url"), - "port": self.config_parser.get("etcd", "port"), - "ca_cert": self.config_parser.get("etcd", "ca_cert"), - "cert_cert": self.config_parser.get( - "etcd", "cert_cert" - ), - "cert_key": self.config_parser.get("etcd", "cert_key"), + 'host': self.config_parser.get('etcd', 'url'), + 'port': self.config_parser.get('etcd', 'port'), + 'ca_cert': self.config_parser.get('etcd', 'ca_cert'), + 'cert_cert': self.config_parser.get('etcd', 'cert_cert'), + 'cert_key': self.config_parser.get('etcd', 'cert_key'), } except configparser.Error as err: raise configparser.Error( - "{} in config file {}".format( + '{} in config file {}'.format( err.message, self.config_file ) ) from err @@ -67,8 +65,8 @@ class Settings(object): wrapper = Etcd3Wrapper(*args, **kwargs) except Exception as err: logger.error( - "etcd connection not successfull. Please check your config file." - "\nDetails: %s\netcd connection parameters: %s", + 'etcd connection not successfull. Please check your config file.' + '\nDetails: %s\netcd connection parameters: %s', err, kwargs, ) @@ -79,15 +77,15 @@ class Settings(object): def read_internal_values(self): self.config_parser.read_dict( { - "etcd": { - "file_prefix": "/files/", - "host_prefix": "/hosts/", - "image_prefix": "/images/", - "image_store_prefix": "/imagestore/", - "network_prefix": "/networks/", - "request_prefix": "/requests/", - "user_prefix": "/users/", - "vm_prefix": "/vms/", + 'etcd': { + 'file_prefix': '/files/', + 'host_prefix': '/hosts/', + 'image_prefix': '/images/', + 'image_store_prefix': '/imagestore/', + 'network_prefix': '/networks/', + 'request_prefix': '/requests/', + 'user_prefix': '/users/', + 'vm_prefix': '/vms/', } } ) @@ -95,15 +93,15 @@ class Settings(object): def read_config_file_values(self, config_file): try: # Trying to read configuration file - with open(config_file, "r") as config_file_handle: + with open(config_file, 'r') as config_file_handle: self.config_parser.read_file(config_file_handle) except FileNotFoundError: sys.exit( - "Configuration file {} not found!".format(config_file) + 'Configuration file {} not found!'.format(config_file) ) except Exception as err: logger.exception(err) - sys.exit("Error occurred while reading configuration file") + sys.exit('Error occurred while reading configuration file') def read_values_from_etcd(self): etcd_client = self.get_etcd_client() @@ -113,7 +111,7 @@ class Settings(object): self.config_parser.read_dict(config_from_etcd.value) self.last_config_update = datetime.utcnow() else: - raise KeyError("Key '{}' not found in etcd. Please configure uncloud.".format(self.config_key)) + raise KeyError('Key \'{}\' not found in etcd. Please configure uncloud.'.format(self.config_key)) def __getitem__(self, key): # Allow failing to read from etcd if we have @@ -121,7 +119,7 @@ class Settings(object): if key not in self.config_parser.sections(): try: self.read_values_from_etcd() - except KeyError as e: + except KeyError: pass return self.config_parser[key] diff --git a/uncloud/vmm/__init__.py b/uncloud/vmm/__init__.py index 6cdd938..4c893f6 100644 --- a/uncloud/vmm/__init__.py +++ b/uncloud/vmm/__init__.py @@ -190,18 +190,10 @@ class VMM: err.stderr.decode("utf-8"), ) else: - with suppress(sp.CalledProcessError): - sp.check_output( - [ - "sudo", - "-p", - "Enter password to correct permission for uncloud-vmm's directory", - "chmod", - "-R", - "o=rwx,g=rwx", - self.vmm_backend, - ] - ) + sp.check_output( + ["sudo", "-p", "Enter password to correct permission for uncloud-vmm's directory", + "chmod", "-R", "o=rwx,g=rwx", self.vmm_backend] + ) # TODO: Find some good way to check whether the virtual machine is up and # running without relying on non-guarenteed ways. From ef0f13534a12ad492782c1baa583b6bab934f223 Mon Sep 17 00:00:00 2001 From: meow Date: Sun, 5 Jan 2020 21:59:24 +0500 Subject: [PATCH 123/284] bug fixed that add extra space in QEMU command when there is no network to be attached --- uncloud/host/virtualmachine.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/uncloud/host/virtualmachine.py b/uncloud/host/virtualmachine.py index cbb3bbe..b9a9e36 100755 --- a/uncloud/host/virtualmachine.py +++ b/uncloud/host/virtualmachine.py @@ -153,7 +153,10 @@ class VM: ) ) - return command.split(" ") + if command: + command = command.split(' ') + + return command def delete_network_dev(self): try: From 388127bd11e45eb9eda1be1279030ca4d2901ae6 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 5 Jan 2020 18:32:14 +0100 Subject: [PATCH 124/284] [hack] add scripts to start VM --- uncloud/hack/uncloud-hack-init-host | 26 ++++++++++++++++++++++++++ uncloud/hack/uncloud-run-vm | 21 +++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 uncloud/hack/uncloud-hack-init-host create mode 100644 uncloud/hack/uncloud-run-vm diff --git a/uncloud/hack/uncloud-hack-init-host b/uncloud/hack/uncloud-hack-init-host new file mode 100644 index 0000000..787ff80 --- /dev/null +++ b/uncloud/hack/uncloud-hack-init-host @@ -0,0 +1,26 @@ +id=100 +rawdev=eth0 + +# create vxlan +ip -6 link add vxlan${id} type vxlan \ + id ${id} \ + dstport 4789 \ + group ff05::${id} \ + dev ${rawdev} \ + ttl 5 + +ip link set vxlan${id} up + +# create bridge +ip link set vxlan${id} up +ip link set br${id} up + +# Add vxlan into bridge +ip link set vxlan${id} master br${id} + + +# useradd -m uncloud +# [18:05] tablett.place10:~# id uncloud +# uid=1000(uncloud) gid=1000(uncloud) groups=1000(uncloud),34(kvm),36(qemu) +# apk add qemu-system-x86_64 +# also needs group netdev diff --git a/uncloud/hack/uncloud-run-vm b/uncloud/hack/uncloud-run-vm new file mode 100644 index 0000000..1af2037 --- /dev/null +++ b/uncloud/hack/uncloud-run-vm @@ -0,0 +1,21 @@ +#!/bin/sh + +if [ $# -ne 1 ]; then + echo $0 vmid + exit 1 +fi + +id=$1; shift + +memory=512 +macaddress=02:00:b9:cb:70:${id} +netname=net${id}-1 + +qemu-system-x86_64 \ + -name uncloud-${id} \ + -accel kvm \ + -m ${memory} \ + -smp 2,sockets=2,cores=1,threads=1 \ + -device virtio-net-pci,netdev=net0,mac=$macaddress \ + -netdev tap,id=net0,ifname=${netname},script=no,downscript=no \ + -vnc [::]:5900 From 6086fec633d6f368f574215631e8449c374b78dd Mon Sep 17 00:00:00 2001 From: meow Date: Mon, 6 Jan 2020 12:25:59 +0500 Subject: [PATCH 125/284] move settings under uncloud.common --- uncloud/api/common_fields.py | 2 +- uncloud/api/create_image_store.py | 2 +- uncloud/api/helper.py | 2 +- uncloud/api/main.py | 2 +- uncloud/api/schemas.py | 2 +- uncloud/cli/helper.py | 2 +- uncloud/common/network.py | 2 -- uncloud/{settings/__init__.py => common/settings.py} | 0 uncloud/common/storage_handlers.py | 2 +- uncloud/configure/main.py | 2 +- uncloud/docs/README.md | 12 ++++++++++++ uncloud/filescanner/main.py | 2 +- uncloud/host/main.py | 2 +- uncloud/host/virtualmachine.py | 2 +- uncloud/imagescanner/main.py | 2 +- uncloud/metadata/main.py | 2 +- uncloud/scheduler/helper.py | 2 +- uncloud/scheduler/main.py | 2 +- uncloud/shared/__init__.py | 2 +- 19 files changed, 28 insertions(+), 18 deletions(-) rename uncloud/{settings/__init__.py => common/settings.py} (100%) create mode 100644 uncloud/docs/README.md diff --git a/uncloud/api/common_fields.py b/uncloud/api/common_fields.py index 8bcf777..adf7cdc 100755 --- a/uncloud/api/common_fields.py +++ b/uncloud/api/common_fields.py @@ -1,7 +1,7 @@ import os from uncloud.shared import shared -from uncloud.settings import settings +from uncloud.common.settings import settings class Optional: diff --git a/uncloud/api/create_image_store.py b/uncloud/api/create_image_store.py index 9259be6..075f26f 100755 --- a/uncloud/api/create_image_store.py +++ b/uncloud/api/create_image_store.py @@ -4,7 +4,7 @@ import os from uuid import uuid4 from uncloud.shared import shared -from uncloud.settings import settings +from uncloud.common.settings import settings data = { 'is_public': True, diff --git a/uncloud/api/helper.py b/uncloud/api/helper.py index c806814..0e5fa19 100755 --- a/uncloud/api/helper.py +++ b/uncloud/api/helper.py @@ -8,7 +8,7 @@ import requests from pyotp import TOTP from uncloud.shared import shared -from uncloud.settings import settings +from uncloud.common.settings import settings logger = logging.getLogger(__name__) diff --git a/uncloud/api/main.py b/uncloud/api/main.py index 84eb28a..401c11f 100644 --- a/uncloud/api/main.py +++ b/uncloud/api/main.py @@ -13,7 +13,7 @@ from werkzeug.exceptions import HTTPException from uncloud.common import counters from uncloud.common.vm import VMStatus from uncloud.common.request import RequestEntry, RequestType -from uncloud.settings import settings +from uncloud.common.settings import settings from uncloud.shared import shared from . import schemas from .helper import generate_mac, mac2ipv6 diff --git a/uncloud/api/schemas.py b/uncloud/api/schemas.py index 8e06e8d..f606803 100755 --- a/uncloud/api/schemas.py +++ b/uncloud/api/schemas.py @@ -22,7 +22,7 @@ import bitmath from uncloud.common.host import HostStatus from uncloud.common.vm import VMStatus from uncloud.shared import shared -from uncloud.settings import settings +from uncloud.common.settings import settings from . import helper, logger from .common_fields import Field, VmUUIDField from .helper import check_otp, resolve_vm_name diff --git a/uncloud/cli/helper.py b/uncloud/cli/helper.py index bdcce78..0495fac 100644 --- a/uncloud/cli/helper.py +++ b/uncloud/cli/helper.py @@ -5,7 +5,7 @@ import binascii from pyotp import TOTP from os.path import join as join_path -from uncloud.settings import settings +from uncloud.common.settings import settings def get_otp_parser(): diff --git a/uncloud/common/network.py b/uncloud/common/network.py index adba108..32f6951 100644 --- a/uncloud/common/network.py +++ b/uncloud/common/network.py @@ -1,8 +1,6 @@ import subprocess as sp import random import logging -import socket -from contextlib import closing logger = logging.getLogger(__name__) diff --git a/uncloud/settings/__init__.py b/uncloud/common/settings.py similarity index 100% rename from uncloud/settings/__init__.py rename to uncloud/common/settings.py diff --git a/uncloud/common/storage_handlers.py b/uncloud/common/storage_handlers.py index 06751c4..6f9b29e 100644 --- a/uncloud/common/storage_handlers.py +++ b/uncloud/common/storage_handlers.py @@ -7,7 +7,7 @@ from abc import ABC from . import logger from os.path import join as join_path -from uncloud.settings import settings as config +from uncloud.common.settings import settings as config class ImageStorageHandler(ABC): diff --git a/uncloud/configure/main.py b/uncloud/configure/main.py index f89a30c..64b40c0 100644 --- a/uncloud/configure/main.py +++ b/uncloud/configure/main.py @@ -1,7 +1,7 @@ import os import argparse -from uncloud.settings import settings +from uncloud.common.settings import settings from uncloud.shared import shared arg_parser = argparse.ArgumentParser('configure', add_help=False) diff --git a/uncloud/docs/README.md b/uncloud/docs/README.md new file mode 100644 index 0000000..a5afbaa --- /dev/null +++ b/uncloud/docs/README.md @@ -0,0 +1,12 @@ +# uncloud docs + +## Requirements +1. Python3 +2. Sphinx + +## Usage +Run `make build` to build docs. + +Run `make clean` to remove build directory. + +Run `make publish` to push build dir to https://ungleich.ch/ucloud/ \ No newline at end of file diff --git a/uncloud/filescanner/main.py b/uncloud/filescanner/main.py index 19c43ef..9d2b2f6 100755 --- a/uncloud/filescanner/main.py +++ b/uncloud/filescanner/main.py @@ -9,7 +9,7 @@ import bitmath from uuid import uuid4 from . import logger -from uncloud.settings import settings +from uncloud.common.settings import settings from uncloud.shared import shared diff --git a/uncloud/host/main.py b/uncloud/host/main.py index b7c8b1c..bed068b 100755 --- a/uncloud/host/main.py +++ b/uncloud/host/main.py @@ -6,7 +6,7 @@ from uuid import uuid4 from uncloud.common.request import RequestEntry, RequestType from uncloud.shared import shared -from uncloud.settings import settings +from uncloud.common.settings import settings from uncloud.common.vm import VMStatus from uncloud.vmm import VMM from os.path import join as join_path diff --git a/uncloud/host/virtualmachine.py b/uncloud/host/virtualmachine.py index b9a9e36..a37dee4 100755 --- a/uncloud/host/virtualmachine.py +++ b/uncloud/host/virtualmachine.py @@ -17,7 +17,7 @@ from uncloud.common.network import create_dev, delete_network_interface from uncloud.common.schemas import VMSchema, NetworkSchema from uncloud.host import logger from uncloud.shared import shared -from uncloud.settings import settings +from uncloud.common.settings import settings from uncloud.vmm import VMM from marshmallow import ValidationError diff --git a/uncloud/imagescanner/main.py b/uncloud/imagescanner/main.py index a43a36c..cb13ac7 100755 --- a/uncloud/imagescanner/main.py +++ b/uncloud/imagescanner/main.py @@ -4,7 +4,7 @@ import argparse import subprocess as sp from os.path import join as join_path -from uncloud.settings import settings +from uncloud.common.settings import settings from uncloud.shared import shared from uncloud.imagescanner import logger diff --git a/uncloud/metadata/main.py b/uncloud/metadata/main.py index e2199b8..d20122e 100644 --- a/uncloud/metadata/main.py +++ b/uncloud/metadata/main.py @@ -5,7 +5,7 @@ from flask import Flask, request from flask_restful import Resource, Api from werkzeug.exceptions import HTTPException -from uncloud.settings import settings +from uncloud.common.settings import settings from uncloud.shared import shared app = Flask(__name__) diff --git a/uncloud/scheduler/helper.py b/uncloud/scheduler/helper.py index 7edf623..a7fec15 100755 --- a/uncloud/scheduler/helper.py +++ b/uncloud/scheduler/helper.py @@ -7,7 +7,7 @@ from uncloud.common.host import HostStatus from uncloud.common.request import RequestEntry, RequestType from uncloud.common.vm import VMStatus from uncloud.shared import shared -from uncloud.settings import settings +from uncloud.common.settings import settings def accumulated_specs(vms_specs): diff --git a/uncloud/scheduler/main.py b/uncloud/scheduler/main.py index 1ef6226..5143537 100755 --- a/uncloud/scheduler/main.py +++ b/uncloud/scheduler/main.py @@ -8,7 +8,7 @@ import argparse from uncloud.common.request import RequestEntry, RequestType from uncloud.shared import shared -from uncloud.settings import settings +from uncloud.common.settings import settings from .helper import (dead_host_mitigation, dead_host_detection, assign_host, NoSuitableHostFound) from . import logger diff --git a/uncloud/shared/__init__.py b/uncloud/shared/__init__.py index db2093f..918dd0c 100644 --- a/uncloud/shared/__init__.py +++ b/uncloud/shared/__init__.py @@ -1,4 +1,4 @@ -from uncloud.settings import settings +from uncloud.common.settings import settings from uncloud.common.vm import VmPool from uncloud.common.host import HostPool from uncloud.common.request import RequestPool From 48cc37c438c015ad7b09e58349e8fad75dd0f49f Mon Sep 17 00:00:00 2001 From: meow Date: Tue, 7 Jan 2020 17:57:44 +0500 Subject: [PATCH 126/284] add hostname to file entry (uncloud filescanner) --- uncloud/filescanner/main.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/uncloud/filescanner/main.py b/uncloud/filescanner/main.py index 9d2b2f6..cb5f2b7 100755 --- a/uncloud/filescanner/main.py +++ b/uncloud/filescanner/main.py @@ -14,6 +14,7 @@ from uncloud.shared import shared arg_parser = argparse.ArgumentParser('filescanner', add_help=False) +arg_parser.add_argument('--hostname', required=True) def sha512sum(file: str): @@ -41,7 +42,7 @@ def sha512sum(file: str): return None -def track_file(file, base_dir): +def track_file(file, base_dir, host): file_path = file.relative_to(base_dir) file_str = str(file) # Get Username @@ -60,6 +61,7 @@ def track_file(file, base_dir): 'sha512sum': sha512sum(file_str), 'creation_date': creation_date, 'size': str(bitmath.Byte(os.path.getsize(file_str)).to_MB()), + 'host': host } logger.info('Tracking %s', file_str) @@ -67,7 +69,7 @@ def track_file(file, base_dir): shared.etcd_client.put(entry_key, entry_value, value_in_json=True) -def main(debug=False): +def main(hostname, debug=False): base_dir = settings['storage']['file_dir'] # Recursively Get All Files and Folder below BASE_DIR files = glob.glob('{}/**'.format(base_dir), recursive=True) @@ -77,11 +79,8 @@ def main(debug=False): tracked_files = [ pathlib.Path(os.path.join(base_dir, f.value['owner'], f.value['filename'])) for f in shared.etcd_client.get_prefix(settings['etcd']['file_prefix'], value_in_json=True) + if f.value['host'] == hostname ] untracked_files = set(files) - set(tracked_files) for file in untracked_files: - track_file(file, base_dir) - - -if __name__ == '__main__': - main() + track_file(file, base_dir, hostname) From b4292615de88018abfb19383eec3b37b680d600d Mon Sep 17 00:00:00 2001 From: meow Date: Tue, 7 Jan 2020 18:27:22 +0500 Subject: [PATCH 127/284] Display more info about tracked files to user e.g creation_date, host on which it is stored, size etc --- uncloud/api/main.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/uncloud/api/main.py b/uncloud/api/main.py index 401c11f..50bc201 100644 --- a/uncloud/api/main.py +++ b/uncloud/api/main.py @@ -289,18 +289,16 @@ class ListUserFiles(Resource): settings['etcd']['file_prefix'], value_in_json=True ) return_files = [] - user_files = list( - filter( - lambda f: f.value['owner'] == data['name'], files - ) - ) + user_files = [f for f in files if f.value['owner'] == data['name']] for file in user_files: - return_files.append( - { - 'filename': file.value['filename'], - 'uuid': file.key.split('/')[-1], - } - ) + file_uuid = file.key.split('/')[-1] + file = file.value + file['uuid'] = file_uuid + + file.pop('sha512sum', None) + file.pop('owner', None) + + return_files.append(file) return {'message': return_files}, 200 else: return validator.get_errors(), 400 From 6046015c3d56067d0eafa3848c59ea333e2a50e7 Mon Sep 17 00:00:00 2001 From: meow Date: Tue, 7 Jan 2020 20:26:10 +0500 Subject: [PATCH 128/284] Add base prefix option for uncloud so that we can run independent instance on uncloud --- conf/uncloud.conf | 2 ++ uncloud/common/settings.py | 38 +++++++++++++++++++++----------------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/conf/uncloud.conf b/conf/uncloud.conf index 9d4358d..9995696 100644 --- a/conf/uncloud.conf +++ b/conf/uncloud.conf @@ -1,6 +1,7 @@ [etcd] url = localhost port = 2379 +prefix = / ca_cert cert_cert cert_key @@ -9,3 +10,4 @@ cert_key name = replace_me realm = replace_me seed = replace_me +api_server = http://localhost:5000 \ No newline at end of file diff --git a/uncloud/common/settings.py b/uncloud/common/settings.py index 0654b9b..7004055 100644 --- a/uncloud/common/settings.py +++ b/uncloud/common/settings.py @@ -4,8 +4,8 @@ import sys import os from datetime import datetime - from uncloud.common.etcd_wrapper import Etcd3Wrapper +from os.path import join as join_path logger = logging.getLogger(__name__) @@ -25,25 +25,28 @@ class CustomConfigParser(configparser.RawConfigParser): class Settings(object): - def __init__(self, config_key='/uncloud/config/'): + def __init__(self): conf_name = 'uncloud.conf' - conf_dir = os.environ.get( - 'UCLOUD_CONF_DIR', os.path.expanduser('~/uncloud/') - ) - self.config_file = os.path.join(conf_dir, conf_name) - self.config_parser = CustomConfigParser(allow_no_value=True) - self.config_key = config_key + conf_dir = os.environ.get('UCLOUD_CONF_DIR', os.path.expanduser('~/uncloud/')) + self.config_file = join_path(conf_dir, conf_name) # this is used to cache config from etcd for 1 minutes. Without this we # would make a lot of requests to etcd which slows down everything. self.last_config_update = datetime.fromtimestamp(0) - self.read_internal_values() + self.config_parser = CustomConfigParser(allow_no_value=True) + self.config_parser.add_section('etcd') + self.config_parser.set('etcd', 'prefix', '/') + + self.config_key = join_path(self['etcd']['prefix'], '/uncloud/config/') + try: self.config_parser.read(self.config_file) except Exception as err: logger.error('%s', err) + self.read_internal_values() + def get_etcd_client(self): args = tuple() try: @@ -75,17 +78,18 @@ class Settings(object): return wrapper def read_internal_values(self): + prefix = self['etcd']['prefix'] self.config_parser.read_dict( { 'etcd': { - 'file_prefix': '/files/', - 'host_prefix': '/hosts/', - 'image_prefix': '/images/', - 'image_store_prefix': '/imagestore/', - 'network_prefix': '/networks/', - 'request_prefix': '/requests/', - 'user_prefix': '/users/', - 'vm_prefix': '/vms/', + 'file_prefix': join_path(prefix, '/files/'), + 'host_prefix': join_path(prefix, '/hosts/'), + 'image_prefix': join_path(prefix, '/images/'), + 'image_store_prefix': join_path(prefix, '/imagestore/'), + 'network_prefix': join_path(prefix, '/networks/'), + 'request_prefix': join_path(prefix, '/requests/'), + 'user_prefix': join_path(prefix, '/users/'), + 'vm_prefix': join_path(prefix, '/vms/'), } } ) From 5a646aeac951ad28b146769275a55a14576f161c Mon Sep 17 00:00:00 2001 From: meow Date: Tue, 7 Jan 2020 21:45:11 +0500 Subject: [PATCH 129/284] prefix is renamed to base_prefix, uncloud now respects base_prefix and put things under it --- conf/uncloud.conf | 2 +- uncloud/api/main.py | 4 ++-- uncloud/common/settings.py | 28 ++++++++++++++++------------ uncloud/configure/main.py | 8 ++------ uncloud/metadata/main.py | 27 --------------------------- 5 files changed, 21 insertions(+), 48 deletions(-) diff --git a/conf/uncloud.conf b/conf/uncloud.conf index 9995696..6a1b500 100644 --- a/conf/uncloud.conf +++ b/conf/uncloud.conf @@ -1,7 +1,7 @@ [etcd] url = localhost port = 2379 -prefix = / +base_prefix = / ca_cert cert_cert cert_key diff --git a/uncloud/api/main.py b/uncloud/api/main.py index 50bc201..d8beb49 100644 --- a/uncloud/api/main.py +++ b/uncloud/api/main.py @@ -59,7 +59,7 @@ class CreateVM(Resource): macs = [generate_mac() for _ in range(len(data['network']))] tap_ids = [ counters.increment_etcd_counter( - shared.etcd_client, '/v1/counter/tap' + shared.etcd_client, settings['etcd']['counter']['tap'] ) for _ in range(len(data['network'])) ] @@ -470,7 +470,7 @@ class CreateNetwork(Resource): network_entry = { 'id': counters.increment_etcd_counter( - shared.etcd_client, '/v1/counter/vxlan' + shared.etcd_client, settings['etcd']['counter']['vxlan'] ), 'type': data['type'], } diff --git a/uncloud/common/settings.py b/uncloud/common/settings.py index 7004055..9db4afe 100644 --- a/uncloud/common/settings.py +++ b/uncloud/common/settings.py @@ -36,15 +36,15 @@ class Settings(object): self.config_parser = CustomConfigParser(allow_no_value=True) self.config_parser.add_section('etcd') - self.config_parser.set('etcd', 'prefix', '/') - - self.config_key = join_path(self['etcd']['prefix'], '/uncloud/config/') + self.config_parser.set('etcd', 'base_prefix', '/') try: self.config_parser.read(self.config_file) except Exception as err: logger.error('%s', err) + self.config_key = join_path(self['etcd']['base_prefix'] + 'uncloud/config/') + self.read_internal_values() def get_etcd_client(self): @@ -78,18 +78,22 @@ class Settings(object): return wrapper def read_internal_values(self): - prefix = self['etcd']['prefix'] + base_prefix = self['etcd']['base_prefix'] self.config_parser.read_dict( { 'etcd': { - 'file_prefix': join_path(prefix, '/files/'), - 'host_prefix': join_path(prefix, '/hosts/'), - 'image_prefix': join_path(prefix, '/images/'), - 'image_store_prefix': join_path(prefix, '/imagestore/'), - 'network_prefix': join_path(prefix, '/networks/'), - 'request_prefix': join_path(prefix, '/requests/'), - 'user_prefix': join_path(prefix, '/users/'), - 'vm_prefix': join_path(prefix, '/vms/'), + 'file_prefix': join_path(base_prefix, 'files/'), + 'host_prefix': join_path(base_prefix, 'hosts/'), + 'image_prefix': join_path(base_prefix, 'images/'), + 'image_store_prefix': join_path(base_prefix, 'imagestore/'), + 'network_prefix': join_path(base_prefix, 'networks/'), + 'request_prefix': join_path(base_prefix, 'requests/'), + 'user_prefix': join_path(base_prefix, 'users/'), + 'vm_prefix': join_path(base_prefix, 'vms/'), + 'counter': { + 'vxlan': join_path(base_prefix, 'counters/vxlan'), + 'tap': join_path(base_prefix, 'counters/tap') + } } } ) diff --git a/uncloud/configure/main.py b/uncloud/configure/main.py index 64b40c0..f3e9717 100644 --- a/uncloud/configure/main.py +++ b/uncloud/configure/main.py @@ -40,18 +40,14 @@ ceph_storage_parser.add_argument('--ceph-image-pool', required=True) def update_config(section, kwargs): - uncloud_config = shared.etcd_client.get( - settings.config_key, value_in_json=True - ) + uncloud_config = shared.etcd_client.get(settings.config_key, value_in_json=True) if not uncloud_config: uncloud_config = {} else: uncloud_config = uncloud_config.value uncloud_config[section] = kwargs - shared.etcd_client.put( - settings.config_key, uncloud_config, value_in_json=True - ) + shared.etcd_client.put(settings.config_key, uncloud_config, value_in_json=True) def main(**kwargs): diff --git a/uncloud/metadata/main.py b/uncloud/metadata/main.py index d20122e..03469a5 100644 --- a/uncloud/metadata/main.py +++ b/uncloud/metadata/main.py @@ -84,33 +84,6 @@ class Root(Resource): data.value["metadata"]["ssh-keys"] += user_personal_ssh_keys return data.value["metadata"], 200 - @staticmethod - def post(): - return {"message": "Previous Implementation is deprecated."} - # data = etcd_client.get("/v1/metadata/{}".format(request.remote_addr), value_in_json=True) - # print(data) - # if data: - # for k in request.json: - # if k not in data.value: - # data.value[k] = request.json[k] - # if k.endswith("-list"): - # data.value[k] = [request.json[k]] - # else: - # if k.endswith("-list"): - # data.value[k].append(request.json[k]) - # else: - # data.value[k] = request.json[k] - # etcd_client.put("/v1/metadata/{}".format(request.remote_addr), - # data.value, value_in_json=True) - # else: - # data = {} - # for k in request.json: - # data[k] = request.json[k] - # if k.endswith("-list"): - # data[k] = [request.json[k]] - # etcd_client.put("/v1/metadata/{}".format(request.remote_addr), - # data, value_in_json=True) - api.add_resource(Root, "/") From f8f790e7fcdc8ee2787810d3b65d23f9a18a11a1 Mon Sep 17 00:00:00 2001 From: meow Date: Tue, 7 Jan 2020 22:18:13 +0500 Subject: [PATCH 130/284] nested dict doesn't play well with configparser --- uncloud/api/main.py | 4 ++-- uncloud/common/settings.py | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/uncloud/api/main.py b/uncloud/api/main.py index d8beb49..e8e85fb 100644 --- a/uncloud/api/main.py +++ b/uncloud/api/main.py @@ -59,7 +59,7 @@ class CreateVM(Resource): macs = [generate_mac() for _ in range(len(data['network']))] tap_ids = [ counters.increment_etcd_counter( - shared.etcd_client, settings['etcd']['counter']['tap'] + shared.etcd_client, settings['etcd']['tap_counter'] ) for _ in range(len(data['network'])) ] @@ -470,7 +470,7 @@ class CreateNetwork(Resource): network_entry = { 'id': counters.increment_etcd_counter( - shared.etcd_client, settings['etcd']['counter']['vxlan'] + shared.etcd_client, settings['etcd']['vxlan_counter'] ), 'type': data['type'], } diff --git a/uncloud/common/settings.py b/uncloud/common/settings.py index 9db4afe..47ad5a7 100644 --- a/uncloud/common/settings.py +++ b/uncloud/common/settings.py @@ -90,10 +90,8 @@ class Settings(object): 'request_prefix': join_path(base_prefix, 'requests/'), 'user_prefix': join_path(base_prefix, 'users/'), 'vm_prefix': join_path(base_prefix, 'vms/'), - 'counter': { - 'vxlan': join_path(base_prefix, 'counters/vxlan'), - 'tap': join_path(base_prefix, 'counters/tap') - } + 'vxlan_counter': join_path(base_prefix, 'counters/vxlan'), + 'tap_counter': join_path(base_prefix, 'counters/tap') } } ) @@ -129,7 +127,6 @@ class Settings(object): self.read_values_from_etcd() except KeyError: pass - return self.config_parser[key] From 48efcdf08cf620e31489df840d815e7369a41dfa Mon Sep 17 00:00:00 2001 From: meow Date: Thu, 9 Jan 2020 00:40:05 +0500 Subject: [PATCH 131/284] 1. mp.set_start_method('spawn') commented out from scripts/uncloud 2. uncloud.shared moved under uncloud.common 3. Refactoring in etcd_wrapper e.g timeout mechanism removed and few other things 4. uncloud-{scheduler,host} now better handle etcd events in their block state (waiting for requests to come) --- scripts/uncloud | 2 +- uncloud/api/common_fields.py | 2 +- uncloud/api/create_image_store.py | 2 +- uncloud/api/helper.py | 3 +- uncloud/api/main.py | 3 +- uncloud/api/schemas.py | 2 +- uncloud/common/etcd_wrapper.py | 113 +++++++----------- uncloud/common/request.py | 11 +- .../{shared/__init__.py => common/shared.py} | 0 uncloud/configure/main.py | 2 +- uncloud/filescanner/main.py | 3 +- uncloud/host/main.py | 82 ++++++------- uncloud/host/virtualmachine.py | 2 +- uncloud/imagescanner/main.py | 2 +- uncloud/metadata/main.py | 2 +- uncloud/scheduler/helper.py | 2 +- uncloud/scheduler/main.py | 76 ++++++------ 17 files changed, 136 insertions(+), 173 deletions(-) rename uncloud/{shared/__init__.py => common/shared.py} (100%) diff --git a/scripts/uncloud b/scripts/uncloud index 968ace6..1ca9c68 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -45,7 +45,7 @@ if __name__ == '__main__': # i.e inheriting few things from parent process etcd3 module # errors out, so the following command configure multiprocessing # module to not inherit anything from parent. - mp.set_start_method('spawn') + # mp.set_start_method('spawn') arguments = vars(args) try: name = arguments.pop('command') diff --git a/uncloud/api/common_fields.py b/uncloud/api/common_fields.py index adf7cdc..d1fcb64 100755 --- a/uncloud/api/common_fields.py +++ b/uncloud/api/common_fields.py @@ -1,6 +1,6 @@ import os -from uncloud.shared import shared +from uncloud.common.shared import shared from uncloud.common.settings import settings diff --git a/uncloud/api/create_image_store.py b/uncloud/api/create_image_store.py index 075f26f..1040e97 100755 --- a/uncloud/api/create_image_store.py +++ b/uncloud/api/create_image_store.py @@ -3,7 +3,7 @@ import os from uuid import uuid4 -from uncloud.shared import shared +from uncloud.common.shared import shared from uncloud.common.settings import settings data = { diff --git a/uncloud/api/helper.py b/uncloud/api/helper.py index 0e5fa19..0805280 100755 --- a/uncloud/api/helper.py +++ b/uncloud/api/helper.py @@ -1,13 +1,12 @@ import binascii import ipaddress import random -import subprocess as sp import logging import requests from pyotp import TOTP -from uncloud.shared import shared +from uncloud.common.shared import shared from uncloud.common.settings import settings logger = logging.getLogger(__name__) diff --git a/uncloud/api/main.py b/uncloud/api/main.py index e8e85fb..2d8d035 100644 --- a/uncloud/api/main.py +++ b/uncloud/api/main.py @@ -10,11 +10,12 @@ from flask import Flask, request from flask_restful import Resource, Api from werkzeug.exceptions import HTTPException +from uncloud.common.shared import shared + from uncloud.common import counters from uncloud.common.vm import VMStatus from uncloud.common.request import RequestEntry, RequestType from uncloud.common.settings import settings -from uncloud.shared import shared from . import schemas from .helper import generate_mac, mac2ipv6 from uncloud import UncloudException diff --git a/uncloud/api/schemas.py b/uncloud/api/schemas.py index f606803..e4de9a8 100755 --- a/uncloud/api/schemas.py +++ b/uncloud/api/schemas.py @@ -21,7 +21,7 @@ import bitmath from uncloud.common.host import HostStatus from uncloud.common.vm import VMStatus -from uncloud.shared import shared +from uncloud.common.shared import shared from uncloud.common.settings import settings from . import helper, logger from .common_fields import Field, VmUUIDField diff --git a/uncloud/common/etcd_wrapper.py b/uncloud/common/etcd_wrapper.py index 6a979ba..fe768ac 100644 --- a/uncloud/common/etcd_wrapper.py +++ b/uncloud/common/etcd_wrapper.py @@ -1,24 +1,21 @@ import etcd3 import json -import queue -import copy -from uncloud import UncloudException -from collections import namedtuple from functools import wraps -from . import logger - -PseudoEtcdMeta = namedtuple("PseudoEtcdMeta", ["key"]) +from uncloud import UncloudException +from uncloud.common import logger class EtcdEntry: - # key: str - # value: str - - def __init__(self, meta, value, value_in_json=False): - self.key = meta.key.decode("utf-8") - self.value = value.decode("utf-8") + def __init__(self, meta_or_key, value, value_in_json=False): + if hasattr(meta_or_key, 'key'): + # if meta has attr 'key' then get it + self.key = meta_or_key.key.decode('utf-8') + else: + # otherwise meta is the 'key' + self.key = meta_or_key + self.value = value.decode('utf-8') if value_in_json: self.value = json.loads(self.value) @@ -29,18 +26,12 @@ def readable_errors(func): def wrapper(*args, **kwargs): try: return func(*args, **kwargs) - except etcd3.exceptions.ConnectionFailedError as err: - raise UncloudException( - "Cannot connect to etcd: is etcd running as configured in uncloud.conf?" - ) + except etcd3.exceptions.ConnectionFailedError: + raise UncloudException('Cannot connect to etcd: is etcd running as configured in uncloud.conf?') except etcd3.exceptions.ConnectionTimeoutError as err: - raise etcd3.exceptions.ConnectionTimeoutError( - "etcd connection timeout." - ) from err + raise etcd3.exceptions.ConnectionTimeoutError('etcd connection timeout.') from err except Exception: - logger.exception( - "Some etcd error occured. See syslog for details." - ) + logger.exception('Some etcd error occured. See syslog for details.') return wrapper @@ -64,55 +55,39 @@ class Etcd3Wrapper: _value = json.dumps(_value) if not isinstance(_key, str): - _key = _key.decode("utf-8") + _key = _key.decode('utf-8') return self.client.put(_key, _value, **kwargs) @readable_errors - def get_prefix(self, *args, value_in_json=False, **kwargs): - r = self.client.get_prefix(*args, **kwargs) - for entry in r: - e = EtcdEntry(*entry[::-1], value_in_json=value_in_json) - if e.value: - yield e + def get_prefix(self, *args, value_in_json=False, raise_exception=True, **kwargs): + try: + event_iterator = self.client.get_prefix(*args, **kwargs) + for e in event_iterator: + yield EtcdEntry(*e[::-1], value_in_json=value_in_json) + except Exception as err: + if raise_exception: + raise Exception('Exception in etcd_wrapper.get_prefix') from err + else: + logger.exception('Error in etcd_wrapper') + return iter([]) @readable_errors - def watch_prefix(self, key, timeout=0, value_in_json=False): - timeout_event = EtcdEntry( - PseudoEtcdMeta(key=b"TIMEOUT"), - value=str.encode( - json.dumps({"status": "TIMEOUT", "type": "TIMEOUT"}) - ), - value_in_json=value_in_json, - ) - - event_queue = queue.Queue() - - def add_event_to_queue(event): - if hasattr(event, "events"): - for e in event.events: - if e.value: - event_queue.put( - EtcdEntry( - e, e.value, value_in_json=value_in_json - ) - ) - - self.client.add_watch_prefix_callback(key, add_event_to_queue) - - while True: - try: - while True: - v = event_queue.get(timeout=timeout) - yield v - except queue.Empty: - event_queue.put(copy.deepcopy(timeout_event)) - - -class PsuedoEtcdEntry(EtcdEntry): - def __init__(self, key, value, value_in_json=False): - super().__init__( - PseudoEtcdMeta(key=key.encode("utf-8")), - value, - value_in_json=value_in_json, - ) + def watch_prefix(self, key, raise_exception=True, value_in_json=False): + try: + event_iterator, cancel = self.client.watch_prefix(key) + for e in event_iterator: + if hasattr(e, '_event'): + e = e._event + if e.type == e.PUT: + yield EtcdEntry(e.kv.key, e.kv.value, value_in_json=value_in_json) + except Exception as err: + if raise_exception: + raise Exception('Exception in etcd_wrapper.get_prefix') from err + else: + logger.exception('Error in etcd_wrapper.watch_prefix') + try: + cancel() + except Exception: + pass + return iter([]) diff --git a/uncloud/common/request.py b/uncloud/common/request.py index a8c2d0a..cb0add5 100644 --- a/uncloud/common/request.py +++ b/uncloud/common/request.py @@ -2,8 +2,8 @@ import json from os.path import join from uuid import uuid4 -from .etcd_wrapper import PsuedoEtcdEntry -from .classes import SpecificEtcdEntryBase +from uncloud.common.etcd_wrapper import EtcdEntry +from uncloud.common.classes import SpecificEtcdEntryBase class RequestType: @@ -29,11 +29,8 @@ class RequestEntry(SpecificEtcdEntryBase): @classmethod def from_scratch(cls, request_prefix, **kwargs): - e = PsuedoEtcdEntry( - join(request_prefix, uuid4().hex), - value=json.dumps(kwargs).encode("utf-8"), - value_in_json=True, - ) + e = EtcdEntry(meta_or_key=join(request_prefix, uuid4().hex), + value=json.dumps(kwargs).encode('utf-8'), value_in_json=True) return cls(e) diff --git a/uncloud/shared/__init__.py b/uncloud/common/shared.py similarity index 100% rename from uncloud/shared/__init__.py rename to uncloud/common/shared.py diff --git a/uncloud/configure/main.py b/uncloud/configure/main.py index f3e9717..e190460 100644 --- a/uncloud/configure/main.py +++ b/uncloud/configure/main.py @@ -2,7 +2,7 @@ import os import argparse from uncloud.common.settings import settings -from uncloud.shared import shared +from uncloud.common.shared import shared arg_parser = argparse.ArgumentParser('configure', add_help=False) configure_subparsers = arg_parser.add_subparsers(dest='subcommand') diff --git a/uncloud/filescanner/main.py b/uncloud/filescanner/main.py index cb5f2b7..314481f 100755 --- a/uncloud/filescanner/main.py +++ b/uncloud/filescanner/main.py @@ -10,8 +10,7 @@ from uuid import uuid4 from . import logger from uncloud.common.settings import settings -from uncloud.shared import shared - +from uncloud.common.shared import shared arg_parser = argparse.ArgumentParser('filescanner', add_help=False) arg_parser.add_argument('--hostname', required=True) diff --git a/uncloud/host/main.py b/uncloud/host/main.py index bed068b..695e3d1 100755 --- a/uncloud/host/main.py +++ b/uncloud/host/main.py @@ -5,7 +5,7 @@ import time from uuid import uuid4 from uncloud.common.request import RequestEntry, RequestType -from uncloud.shared import shared +from uncloud.common.shared import shared from uncloud.common.settings import settings from uncloud.common.vm import VMStatus from uncloud.vmm import VMM @@ -72,52 +72,52 @@ def main(hostname, debug=False): except Exception as e: raise Exception('uncloud-host heartbeat updating mechanism is not working') from e - for events_iterator in [ - shared.etcd_client.get_prefix(settings['etcd']['request_prefix'], value_in_json=True), - shared.etcd_client.watch_prefix(settings['etcd']['request_prefix'], timeout=10, value_in_json=True) - ]: - for request_event in events_iterator: - request_event = RequestEntry(request_event) + # The below while True is neccessary for gracefully handling leadership transfer and temporary + # unavailability in etcd. Why does it work? It works because the get_prefix,watch_prefix return + # iter([]) that is iterator of empty list on exception (that occur due to above mentioned reasons) + # which ends the loop immediately. So, having it inside infinite loop we try again and again to + # get prefix until either success or deamon death comes. + while True: + for events_iterator in [ + shared.etcd_client.get_prefix(settings['etcd']['request_prefix'], value_in_json=True, + raise_exception=False), + shared.etcd_client.watch_prefix(settings['etcd']['request_prefix'], value_in_json=True, + raise_exception=False) + ]: + for request_event in events_iterator: + request_event = RequestEntry(request_event) - if request_event.type == 'TIMEOUT': maintenance(host.key) - elif request_event.hostname == host.key: - logger.debug('VM Request: %s on Host %s', request_event, host.hostname) - shared.request_pool.client.client.delete(request_event.key) - vm_entry = shared.etcd_client.get( - join_path(settings['etcd']['vm_prefix'], request_event.uuid) - ) - logger.debug('VM hostname: {}'.format(vm_entry.value)) - vm = virtualmachine.VM(vm_entry) - if request_event.type == RequestType.StartVM: - vm.start() + if request_event.hostname == host.key: + logger.debug('VM Request: %s on Host %s', request_event, host.hostname) - elif request_event.type == RequestType.StopVM: - vm.stop() + shared.request_pool.client.client.delete(request_event.key) + vm_entry = shared.etcd_client.get( + join_path(settings['etcd']['vm_prefix'], request_event.uuid) + ) - elif request_event.type == RequestType.DeleteVM: - vm.delete() + logger.debug('VM hostname: {}'.format(vm_entry.value)) - elif request_event.type == RequestType.InitVMMigration: - vm.start(destination_host_key=host.key) + vm = virtualmachine.VM(vm_entry) + if request_event.type == RequestType.StartVM: + vm.start() - elif request_event.type == RequestType.TransferVM: - destination_host = host_pool.get(request_event.destination_host_key) - if destination_host: - vm.migrate( - destination_host=destination_host.hostname, - destination_sock_path=request_event.destination_sock_path, - ) - else: - logger.error('Host %s not found!', request_event.destination_host_key) + elif request_event.type == RequestType.StopVM: + vm.stop() + elif request_event.type == RequestType.DeleteVM: + vm.delete() -if __name__ == '__main__': - argparser = argparse.ArgumentParser() - argparser.add_argument( - 'hostname', help='Name of this host. e.g uncloud1.ungleich.ch' - ) - args = argparser.parse_args() - mp.set_start_method('spawn') - main(args.hostname) + elif request_event.type == RequestType.InitVMMigration: + vm.start(destination_host_key=host.key) + + elif request_event.type == RequestType.TransferVM: + destination_host = host_pool.get(request_event.destination_host_key) + if destination_host: + vm.migrate( + destination_host=destination_host.hostname, + destination_sock_path=request_event.destination_sock_path, + ) + else: + logger.error('Host %s not found!', request_event.destination_host_key) diff --git a/uncloud/host/virtualmachine.py b/uncloud/host/virtualmachine.py index a37dee4..2f6a5e3 100755 --- a/uncloud/host/virtualmachine.py +++ b/uncloud/host/virtualmachine.py @@ -16,7 +16,7 @@ from uncloud.common.vm import VMStatus, declare_stopped from uncloud.common.network import create_dev, delete_network_interface from uncloud.common.schemas import VMSchema, NetworkSchema from uncloud.host import logger -from uncloud.shared import shared +from uncloud.common.shared import shared from uncloud.common.settings import settings from uncloud.vmm import VMM diff --git a/uncloud/imagescanner/main.py b/uncloud/imagescanner/main.py index cb13ac7..91f100e 100755 --- a/uncloud/imagescanner/main.py +++ b/uncloud/imagescanner/main.py @@ -5,7 +5,7 @@ import subprocess as sp from os.path import join as join_path from uncloud.common.settings import settings -from uncloud.shared import shared +from uncloud.common.shared import shared from uncloud.imagescanner import logger diff --git a/uncloud/metadata/main.py b/uncloud/metadata/main.py index 03469a5..73d59cd 100644 --- a/uncloud/metadata/main.py +++ b/uncloud/metadata/main.py @@ -6,7 +6,7 @@ from flask_restful import Resource, Api from werkzeug.exceptions import HTTPException from uncloud.common.settings import settings -from uncloud.shared import shared +from uncloud.common.shared import shared app = Flask(__name__) api = Api(app) diff --git a/uncloud/scheduler/helper.py b/uncloud/scheduler/helper.py index a7fec15..108d126 100755 --- a/uncloud/scheduler/helper.py +++ b/uncloud/scheduler/helper.py @@ -6,7 +6,7 @@ import bitmath from uncloud.common.host import HostStatus from uncloud.common.request import RequestEntry, RequestType from uncloud.common.vm import VMStatus -from uncloud.shared import shared +from uncloud.common.shared import shared from uncloud.common.settings import settings diff --git a/uncloud/scheduler/main.py b/uncloud/scheduler/main.py index 5143537..20a52cb 100755 --- a/uncloud/scheduler/main.py +++ b/uncloud/scheduler/main.py @@ -6,59 +6,51 @@ import argparse -from uncloud.common.request import RequestEntry, RequestType -from uncloud.shared import shared from uncloud.common.settings import settings -from .helper import (dead_host_mitigation, dead_host_detection, assign_host, NoSuitableHostFound) -from . import logger +from uncloud.common.request import RequestEntry, RequestType +from uncloud.common.shared import shared +from uncloud.scheduler import logger +from uncloud.scheduler.helper import (dead_host_mitigation, dead_host_detection, + assign_host, NoSuitableHostFound) arg_parser = argparse.ArgumentParser('scheduler', add_help=False) def main(debug=False): - for request_iterator in [ - shared.etcd_client.get_prefix( - settings["etcd"]["request_prefix"], value_in_json=True - ), - shared.etcd_client.watch_prefix( - settings["etcd"]["request_prefix"], - timeout=5, - value_in_json=True, - ), - ]: - for request_event in request_iterator: - request_entry = RequestEntry(request_event) - # Never Run time critical mechanism inside timeout - # mechanism because timeout mechanism only comes - # when no other event is happening. It means under - # heavy load there would not be a timeout event. - if request_entry.type == "TIMEOUT": + # The below while True is neccessary for gracefully handling leadership transfer and temporary + # unavailability in etcd. Why does it work? It works because the get_prefix,watch_prefix return + # iter([]) that is iterator of empty list on exception (that occur due to above mentioned reasons) + # which ends the loop immediately. So, having it inside infinite loop we try again and again to + # get prefix until either success or deamon death comes. + while True: + for request_iterator in [ + shared.etcd_client.get_prefix(settings['etcd']['request_prefix'], value_in_json=True, + raise_exception=False), + shared.etcd_client.watch_prefix(settings['etcd']['request_prefix'], value_in_json=True, + raise_exception=False), + ]: + for request_event in request_iterator: + dead_host_mitigation(dead_host_detection()) + request_entry = RequestEntry(request_event) - # Detect hosts that are dead and set their status - # to "DEAD", and their VMs' status to "KILLED" - dead_hosts = dead_host_detection() - if dead_hosts: - logger.debug("Dead hosts: %s", dead_hosts) - dead_host_mitigation(dead_hosts) + if request_entry.type == RequestType.ScheduleVM: + logger.debug('%s, %s', request_entry.key, request_entry.value) - elif request_entry.type == RequestType.ScheduleVM: - logger.debug("%s, %s", request_entry.key, request_entry.value) + vm_entry = shared.vm_pool.get(request_entry.uuid) + if vm_entry is None: + logger.info('Trying to act on {} but it is deleted'.format(request_entry.uuid)) + continue - vm_entry = shared.vm_pool.get(request_entry.uuid) - if vm_entry is None: - logger.info("Trying to act on {} but it is deleted".format(request_entry.uuid)) - continue + shared.etcd_client.client.delete(request_entry.key) # consume Request - shared.etcd_client.client.delete(request_entry.key) # consume Request + try: + assign_host(vm_entry) + except NoSuitableHostFound: + vm_entry.add_log('Can\'t schedule VM. No Resource Left.') + shared.vm_pool.put(vm_entry) - try: - assign_host(vm_entry) - except NoSuitableHostFound: - vm_entry.add_log("Can't schedule VM. No Resource Left.") - shared.vm_pool.put(vm_entry) - - logger.info("No Resource Left. Emailing admin....") + logger.info('No Resource Left. Emailing admin....') -if __name__ == "__main__": +if __name__ == '__main__': main() From feb334cf0451655d8dfc210beef4be11f817781d Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 10 Jan 2020 10:07:01 +0100 Subject: [PATCH 132/284] Exit code == 1 in case we died with an exception --- scripts/uncloud | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/uncloud b/scripts/uncloud index 1ca9c68..533fc4b 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -40,7 +40,6 @@ if __name__ == '__main__': if not args.command: arg_parser.print_help() else: - # if we start etcd in seperate process with default settings # i.e inheriting few things from parent process etcd3 module # errors out, so the following command configure multiprocessing @@ -54,5 +53,7 @@ if __name__ == '__main__': main(**arguments) except UncloudException as err: logger.error(err) + sys.exit(1) except Exception as err: logger.exception(err) + sys.exit(1) From 92f985c857ac872a2a027cec032a65f536d231e1 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 10 Jan 2020 10:10:37 +0100 Subject: [PATCH 133/284] Handle etcd connection error --- uncloud/common/etcd_wrapper.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/uncloud/common/etcd_wrapper.py b/uncloud/common/etcd_wrapper.py index fe768ac..211bd3c 100644 --- a/uncloud/common/etcd_wrapper.py +++ b/uncloud/common/etcd_wrapper.py @@ -65,6 +65,8 @@ class Etcd3Wrapper: event_iterator = self.client.get_prefix(*args, **kwargs) for e in event_iterator: yield EtcdEntry(*e[::-1], value_in_json=value_in_json) + except etcd3.exceptions.ConnectionFailedError as e: + raise UncloudException("Cannot connect to etcd: {}".format(e)) except Exception as err: if raise_exception: raise Exception('Exception in etcd_wrapper.get_prefix') from err From 71fd0ca7d9c141045ea42c34cda4a38730fda789 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 10 Jan 2020 11:00:00 +0100 Subject: [PATCH 134/284] Remove double try/except blocks (with wraps) --- uncloud/common/etcd_wrapper.py | 38 ++++++++-------------------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/uncloud/common/etcd_wrapper.py b/uncloud/common/etcd_wrapper.py index 211bd3c..38471ab 100644 --- a/uncloud/common/etcd_wrapper.py +++ b/uncloud/common/etcd_wrapper.py @@ -61,35 +61,15 @@ class Etcd3Wrapper: @readable_errors def get_prefix(self, *args, value_in_json=False, raise_exception=True, **kwargs): - try: - event_iterator = self.client.get_prefix(*args, **kwargs) - for e in event_iterator: - yield EtcdEntry(*e[::-1], value_in_json=value_in_json) - except etcd3.exceptions.ConnectionFailedError as e: - raise UncloudException("Cannot connect to etcd: {}".format(e)) - except Exception as err: - if raise_exception: - raise Exception('Exception in etcd_wrapper.get_prefix') from err - else: - logger.exception('Error in etcd_wrapper') - return iter([]) + event_iterator = self.client.get_prefix(*args, **kwargs) + for e in event_iterator: + yield EtcdEntry(*e[::-1], value_in_json=value_in_json) @readable_errors def watch_prefix(self, key, raise_exception=True, value_in_json=False): - try: - event_iterator, cancel = self.client.watch_prefix(key) - for e in event_iterator: - if hasattr(e, '_event'): - e = e._event - if e.type == e.PUT: - yield EtcdEntry(e.kv.key, e.kv.value, value_in_json=value_in_json) - except Exception as err: - if raise_exception: - raise Exception('Exception in etcd_wrapper.get_prefix') from err - else: - logger.exception('Error in etcd_wrapper.watch_prefix') - try: - cancel() - except Exception: - pass - return iter([]) + event_iterator, cancel = self.client.watch_prefix(key) + for e in event_iterator: + if hasattr(e, '_event'): + e = e._event + if e.type == e.PUT: + yield EtcdEntry(e.kv.key, e.kv.value, value_in_json=value_in_json) From b7596e071a127c5a3bd1af17bff5efbd400d3dd7 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 10 Jan 2020 11:30:23 +0100 Subject: [PATCH 135/284] begin phasing in arguments instead of **arguments --- scripts/uncloud | 35 +++++++++++++++++++++++++++++++---- uncloud/api/main.py | 5 ++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/scripts/uncloud b/scripts/uncloud index 533fc4b..4f83e9e 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -8,6 +8,17 @@ import multiprocessing as mp from uncloud import UncloudException from contextlib import suppress +# the components that use etcd +ETCD_COMPONENTS= ['api', + 'scheduler', + 'host', + 'filescanner', + 'imagescanner', + 'metadata', + 'configure' ] + +ALL_COMPONENTS = ETCD_COMPONENTS.copy() +ALL_COMPONENTS.append('cli') def exception_hook(exc_type, exc_value, exc_traceback): logging.getLogger(__name__).error( @@ -30,11 +41,25 @@ if __name__ == '__main__': parent_parser.add_argument('--debug', '-d', action='store_true', default=False, help='More verbose logging') - for component in ['api', 'scheduler', 'host', 'filescanner', 'imagescanner', - 'metadata', 'configure', 'cli']: + etcd_parser = argparse.ArgumentParser(add_help=False) + etcd_parser.add_argument('--etcd-host') + etcd_parser.add_argument('--etcd-port') + etcd_parser.add_argument('--etcd-ca-cert', + help="CA that signed the etcd certificate") + etcd_parser.add_argument('--etcd-cert-cert', + help="Path to client certificate") + etcd_parser.add_argument('--etcd-cert-key', + help="Path to client certificate key") + + for component in ALL_COMPONENTS: mod = importlib.import_module('uncloud.{}.main'.format(component)) parser = getattr(mod, 'arg_parser') - subparsers.add_parser(name=parser.prog, parents=[parser, parent_parser]) + + if component in ETCD_COMPONENTS: + subparsers.add_parser(name=parser.prog, parents=[parser, parent_parser, etcd_parser]) + else: + subparsers.add_parser(name=parser.prog, parents=[parser, parent_parser]) + args = arg_parser.parse_args() if not args.command: @@ -46,11 +71,13 @@ if __name__ == '__main__': # module to not inherit anything from parent. # mp.set_start_method('spawn') arguments = vars(args) + print(arguments) + print(etcd_parser) try: name = arguments.pop('command') mod = importlib.import_module('uncloud.{}.main'.format(name)) main = getattr(mod, 'main') - main(**arguments) + main(arguments) except UncloudException as err: logger.error(err) sys.exit(1) diff --git a/uncloud/api/main.py b/uncloud/api/main.py index 2d8d035..de75f07 100644 --- a/uncloud/api/main.py +++ b/uncloud/api/main.py @@ -563,7 +563,10 @@ api.add_resource(ListHost, '/host/list') api.add_resource(CreateNetwork, '/network/create') -def main(debug=False, port=None): +def main(arguments): + debug = arguments['debug'] + port = arguments['port'] + try: image_stores = list( shared.etcd_client.get_prefix( From d9dd6b48dcce5e70f94c0a3c29548258ed6c05ca Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 10 Jan 2020 11:35:04 +0100 Subject: [PATCH 136/284] No try: needed for pop/importlib/getattr --- scripts/uncloud | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/uncloud b/scripts/uncloud index 4f83e9e..6da98e0 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -73,10 +73,14 @@ if __name__ == '__main__': arguments = vars(args) print(arguments) print(etcd_parser) + + name = arguments.pop('command') + mod = importlib.import_module('uncloud.{}.main'.format(name)) + main = getattr(mod, 'main') + + if component in ETCD_COMPONENTS: + import etcd3 try: - name = arguments.pop('command') - mod = importlib.import_module('uncloud.{}.main'.format(name)) - main = getattr(mod, 'main') main(arguments) except UncloudException as err: logger.error(err) From 82a69701ceb554d3838089ef4f92326ebf603d16 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 10 Jan 2020 11:43:53 +0100 Subject: [PATCH 137/284] catch etcd in scripts/ --- scripts/uncloud | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/uncloud b/scripts/uncloud index 6da98e0..4f9b38b 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -71,20 +71,22 @@ if __name__ == '__main__': # module to not inherit anything from parent. # mp.set_start_method('spawn') arguments = vars(args) - print(arguments) - print(etcd_parser) - name = arguments.pop('command') mod = importlib.import_module('uncloud.{}.main'.format(name)) main = getattr(mod, 'main') - if component in ETCD_COMPONENTS: + # If the component requires etcd3, we import it and catch the + # etcd3.exceptions.ConnectionFailedError + if name in ETCD_COMPONENTS: import etcd3 + try: main(arguments) except UncloudException as err: logger.error(err) sys.exit(1) + except etcd3.exceptions.ConnectionFailedError as err: + logger.error("Cannot connect to etcd") except Exception as err: logger.exception(err) sys.exit(1) From 31ec024be621be09b564979cab2f2702e6141329 Mon Sep 17 00:00:00 2001 From: meow Date: Fri, 10 Jan 2020 15:45:48 +0500 Subject: [PATCH 138/284] passing arguments dict to componenets instead of **kwargs --- scripts/uncloud | 30 +++++++----------------------- uncloud/api/main.py | 10 ++-------- uncloud/filescanner/main.py | 3 ++- uncloud/host/main.py | 3 ++- uncloud/imagescanner/main.py | 2 +- uncloud/metadata/main.py | 8 +++----- uncloud/scheduler/main.py | 6 +----- 7 files changed, 18 insertions(+), 44 deletions(-) diff --git a/scripts/uncloud b/scripts/uncloud index 6da98e0..1975f84 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -3,23 +3,16 @@ import logging import sys import importlib import argparse -import multiprocessing as mp from uncloud import UncloudException -from contextlib import suppress # the components that use etcd -ETCD_COMPONENTS= ['api', - 'scheduler', - 'host', - 'filescanner', - 'imagescanner', - 'metadata', - 'configure' ] +ETCD_COMPONENTS = ['api', 'scheduler', 'host', 'filescanner', 'imagescanner', 'metadata', 'configure'] ALL_COMPONENTS = ETCD_COMPONENTS.copy() ALL_COMPONENTS.append('cli') + def exception_hook(exc_type, exc_value, exc_traceback): logging.getLogger(__name__).error( 'Uncaught exception', @@ -44,12 +37,9 @@ if __name__ == '__main__': etcd_parser = argparse.ArgumentParser(add_help=False) etcd_parser.add_argument('--etcd-host') etcd_parser.add_argument('--etcd-port') - etcd_parser.add_argument('--etcd-ca-cert', - help="CA that signed the etcd certificate") - etcd_parser.add_argument('--etcd-cert-cert', - help="Path to client certificate") - etcd_parser.add_argument('--etcd-cert-key', - help="Path to client certificate key") + etcd_parser.add_argument('--etcd-ca-cert', help='CA that signed the etcd certificate') + etcd_parser.add_argument('--etcd-cert-cert', help='Path to client certificate') + etcd_parser.add_argument('--etcd-cert-key', help='Path to client certificate key') for component in ALL_COMPONENTS: mod = importlib.import_module('uncloud.{}.main'.format(component)) @@ -60,19 +50,13 @@ if __name__ == '__main__': else: subparsers.add_parser(name=parser.prog, parents=[parser, parent_parser]) - args = arg_parser.parse_args() if not args.command: arg_parser.print_help() else: - # if we start etcd in seperate process with default settings - # i.e inheriting few things from parent process etcd3 module - # errors out, so the following command configure multiprocessing - # module to not inherit anything from parent. - # mp.set_start_method('spawn') arguments = vars(args) - print(arguments) - print(etcd_parser) + # print(arguments) + # print(etcd_parser) name = arguments.pop('command') mod = importlib.import_module('uncloud.{}.main'.format(name)) diff --git a/uncloud/api/main.py b/uncloud/api/main.py index de75f07..34e1dd1 100644 --- a/uncloud/api/main.py +++ b/uncloud/api/main.py @@ -565,7 +565,7 @@ api.add_resource(CreateNetwork, '/network/create') def main(arguments): debug = arguments['debug'] - port = arguments['port'] + port = arguments['port'] try: image_stores = list( @@ -596,12 +596,6 @@ def main(arguments): # ) try: - app.run(host='::', - port=port, - debug=debug) + app.run(host='::', port=port, debug=debug) except OSError as e: raise UncloudException('Failed to start Flask: {}'.format(e)) - - -if __name__ == '__main__': - main() diff --git a/uncloud/filescanner/main.py b/uncloud/filescanner/main.py index 314481f..c5660dd 100755 --- a/uncloud/filescanner/main.py +++ b/uncloud/filescanner/main.py @@ -68,7 +68,8 @@ def track_file(file, base_dir, host): shared.etcd_client.put(entry_key, entry_value, value_in_json=True) -def main(hostname, debug=False): +def main(arguments): + hostname = arguments['hostname'] base_dir = settings['storage']['file_dir'] # Recursively Get All Files and Folder below BASE_DIR files = glob.glob('{}/**'.format(base_dir), recursive=True) diff --git a/uncloud/host/main.py b/uncloud/host/main.py index 695e3d1..ccffd77 100755 --- a/uncloud/host/main.py +++ b/uncloud/host/main.py @@ -44,7 +44,8 @@ def maintenance(host): shared.vm_pool.put(vm) -def main(hostname, debug=False): +def main(arguments): + hostname = arguments['hostname'] host_pool = shared.host_pool host = next(filter(lambda h: h.hostname == hostname, host_pool.hosts), None) diff --git a/uncloud/imagescanner/main.py b/uncloud/imagescanner/main.py index 91f100e..1803213 100755 --- a/uncloud/imagescanner/main.py +++ b/uncloud/imagescanner/main.py @@ -30,7 +30,7 @@ def qemu_img_type(path): return qemu_img_info["format"] -def main(debug=False): +def main(arguments): # We want to get images entries that requests images to be created images = shared.etcd_client.get_prefix( settings["etcd"]["image_prefix"], value_in_json=True diff --git a/uncloud/metadata/main.py b/uncloud/metadata/main.py index 73d59cd..ccda60e 100644 --- a/uncloud/metadata/main.py +++ b/uncloud/metadata/main.py @@ -88,9 +88,7 @@ class Root(Resource): api.add_resource(Root, "/") -def main(port=None, debug=False): +def main(arguments): + port = arguments['port'] + debug = arguments['debug'] app.run(debug=debug, host="::", port=port) - - -if __name__ == "__main__": - main() diff --git a/uncloud/scheduler/main.py b/uncloud/scheduler/main.py index 20a52cb..c25700b 100755 --- a/uncloud/scheduler/main.py +++ b/uncloud/scheduler/main.py @@ -16,7 +16,7 @@ from uncloud.scheduler.helper import (dead_host_mitigation, dead_host_detection, arg_parser = argparse.ArgumentParser('scheduler', add_help=False) -def main(debug=False): +def main(arguments): # The below while True is neccessary for gracefully handling leadership transfer and temporary # unavailability in etcd. Why does it work? It works because the get_prefix,watch_prefix return # iter([]) that is iterator of empty list on exception (that occur due to above mentioned reasons) @@ -50,7 +50,3 @@ def main(debug=False): shared.vm_pool.put(vm_entry) logger.info('No Resource Left. Emailing admin....') - - -if __name__ == '__main__': - main() From ec66a756a011325a96719a2aada9c7b1b1d6c2fc Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 10 Jan 2020 11:56:47 +0100 Subject: [PATCH 139/284] ++confdir --- scripts/uncloud | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/uncloud b/scripts/uncloud index 4f9b38b..a6747be 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -38,8 +38,12 @@ if __name__ == '__main__': subparsers = arg_parser.add_subparsers(dest='command') parent_parser = argparse.ArgumentParser(add_help=False) - parent_parser.add_argument('--debug', '-d', action='store_true', default=False, + parent_parser.add_argument('--debug', '-d', + action='store_true', + default=False, help='More verbose logging') + parent_parser.add_argument('--conf-dir', '-c', + help='Configuration directory') etcd_parser = argparse.ArgumentParser(add_help=False) etcd_parser.add_argument('--etcd-host') From e91fd9e24af37bcf7703dfd39a8496bfb28ac1f7 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 10 Jan 2020 12:00:02 +0100 Subject: [PATCH 140/284] disable cli until bug #25 is fixed --- scripts/uncloud | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/uncloud b/scripts/uncloud index bfb3174..3690ad9 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -10,7 +10,7 @@ from uncloud import UncloudException ETCD_COMPONENTS = ['api', 'scheduler', 'host', 'filescanner', 'imagescanner', 'metadata', 'configure'] ALL_COMPONENTS = ETCD_COMPONENTS.copy() -ALL_COMPONENTS.append('cli') +#ALL_COMPONENTS.append('cli') def exception_hook(exc_type, exc_value, exc_traceback): From 00d876aea1a92c8aaf2be2a2e95bd42d3985335a Mon Sep 17 00:00:00 2001 From: meow Date: Fri, 10 Jan 2020 16:39:40 +0500 Subject: [PATCH 141/284] Do not break if client section/or OTP creds missing from conf file --- scripts/uncloud | 2 -- uncloud/cli/helper.py | 17 +++++++++++++---- uncloud/cli/main.py | 10 +++++----- uncloud/common/settings.py | 6 ++---- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/scripts/uncloud b/scripts/uncloud index 3690ad9..8aac240 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -73,8 +73,6 @@ if __name__ == '__main__': except UncloudException as err: logger.error(err) sys.exit(1) - except etcd3.exceptions.ConnectionFailedError as err: - logger.error("Cannot connect to etcd") except Exception as err: logger.exception(err) sys.exit(1) diff --git a/uncloud/cli/helper.py b/uncloud/cli/helper.py index 0495fac..3c63073 100644 --- a/uncloud/cli/helper.py +++ b/uncloud/cli/helper.py @@ -10,10 +10,19 @@ from uncloud.common.settings import settings def get_otp_parser(): otp_parser = argparse.ArgumentParser('otp') - otp_parser.add_argument('--name', default=settings['client']['name']) - otp_parser.add_argument('--realm', default=settings['client']['realm']) - otp_parser.add_argument('--seed', type=get_token, default=settings['client']['seed'], - dest='token', metavar='SEED') + try: + name = settings['client']['name'] + realm = settings['client']['realm'] + seed = settings['client']['seed'] + except Exception: + otp_parser.add_argument('--name', required=True) + otp_parser.add_argument('--realm', required=True) + otp_parser.add_argument('--seed', required=True, type=get_token, dest='token', metavar='SEED') + else: + otp_parser.add_argument('--name', default=name) + otp_parser.add_argument('--realm', default=realm) + otp_parser.add_argument('--seed', default=seed, type=get_token, dest='token', metavar='SEED') + return otp_parser diff --git a/uncloud/cli/main.py b/uncloud/cli/main.py index 7f5e367..9a42497 100644 --- a/uncloud/cli/main.py +++ b/uncloud/cli/main.py @@ -12,12 +12,12 @@ for component in ['user', 'host', 'image', 'network', 'vm']: subparser.add_parser(name=parser.prog, parents=[parser]) -def main(**kwargs): - if not kwargs['subcommand']: +def main(arguments): + if not arguments['subcommand']: arg_parser.print_help() else: - name = kwargs.pop('subcommand') - kwargs.pop('debug') + name = arguments.pop('subcommand') + arguments.pop('debug') mod = importlib.import_module('uncloud.cli.{}'.format(name)) _main = getattr(mod, 'main') - _main(**kwargs) + _main(**arguments) diff --git a/uncloud/common/settings.py b/uncloud/common/settings.py index 47ad5a7..0d524a7 100644 --- a/uncloud/common/settings.py +++ b/uncloud/common/settings.py @@ -99,12 +99,10 @@ class Settings(object): def read_config_file_values(self, config_file): try: # Trying to read configuration file - with open(config_file, 'r') as config_file_handle: + with open(config_file) as config_file_handle: self.config_parser.read_file(config_file_handle) except FileNotFoundError: - sys.exit( - 'Configuration file {} not found!'.format(config_file) - ) + sys.exit('Configuration file {} not found!'.format(config_file)) except Exception as err: logger.exception(err) sys.exit('Error occurred while reading configuration file') From cf4930ee84c0b588fd322a34a40e232d9d4c583c Mon Sep 17 00:00:00 2001 From: meow Date: Fri, 10 Jan 2020 16:42:07 +0500 Subject: [PATCH 142/284] cli enabled again --- scripts/uncloud | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/uncloud b/scripts/uncloud index 8aac240..a6e61aa 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -10,7 +10,7 @@ from uncloud import UncloudException ETCD_COMPONENTS = ['api', 'scheduler', 'host', 'filescanner', 'imagescanner', 'metadata', 'configure'] ALL_COMPONENTS = ETCD_COMPONENTS.copy() -#ALL_COMPONENTS.append('cli') +ALL_COMPONENTS.append('cli') def exception_hook(exc_type, exc_value, exc_traceback): From ebcb1680d70114364dfdcab9cc6e8699ffc52f50 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 10 Jan 2020 23:27:21 +0100 Subject: [PATCH 143/284] add hack scripts --- uncloud/hack/hackcloud/foo | 2 ++ uncloud/hack/hackcloud/ifup.sh | 8 ++++++ uncloud/hack/hackcloud/net.sh | 21 +++++++++++++++ uncloud/hack/hackcloud/vm.sh | 48 ++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 uncloud/hack/hackcloud/foo create mode 100755 uncloud/hack/hackcloud/ifup.sh create mode 100755 uncloud/hack/hackcloud/net.sh create mode 100755 uncloud/hack/hackcloud/vm.sh diff --git a/uncloud/hack/hackcloud/foo b/uncloud/hack/hackcloud/foo new file mode 100644 index 0000000..1033abf --- /dev/null +++ b/uncloud/hack/hackcloud/foo @@ -0,0 +1,2 @@ +tap0 +tap0 diff --git a/uncloud/hack/hackcloud/ifup.sh b/uncloud/hack/hackcloud/ifup.sh new file mode 100755 index 0000000..95bfe5a --- /dev/null +++ b/uncloud/hack/hackcloud/ifup.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +echo $@ >> foo + +dev=$1; shift + +# bridge is setup from outside +ip link set dev "$dev" master ${bridge} diff --git a/uncloud/hack/hackcloud/net.sh b/uncloud/hack/hackcloud/net.sh new file mode 100755 index 0000000..e56822f --- /dev/null +++ b/uncloud/hack/hackcloud/net.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +netid=100 +dev=wlp2s0 +dev=wlp0s20f3 +dev=wlan0 + +vxlandev=vxlan${netid} +bridgedev=br${netid} + +ip -6 link add ${vxlandev} type vxlan \ + id ${netid} \ + dstport 4789 \ + group ff05::${netid} \ + dev ${dev} \ + ttl 5 + +ip link set ${vxlandev} up + +ip link add ${bridgedev} type bridge +ip link set ${bridgedev} up diff --git a/uncloud/hack/hackcloud/vm.sh b/uncloud/hack/hackcloud/vm.sh new file mode 100755 index 0000000..2a8b794 --- /dev/null +++ b/uncloud/hack/hackcloud/vm.sh @@ -0,0 +1,48 @@ +#!/bin/sh + +vmid=$1; shift + +qemu=/usr/bin/qemu-system-x86_64 + +accel=kvm +accel=tcg + +memory=1024 +cores=2 +uuid=732e08c7-84f8-4d43-9571-263db4f80080 + +export bridge=br100 + +$qemu -name uc${vmid} \ + -machine pc,accel=${accel} \ + -m ${memory} \ + -smp ${cores} \ + -uuid ${uuid} \ + -drive file=alpine-virt-3.11.2-x86_64.iso,media=cdrom \ + -netdev tap,id=netmain,script=./ifup.sh \ + -device virtio-net-pci,netdev=netmain,id=net0,mac=02:00:f0:a9:c4:4e + + + +exit 0 + +-S -object secret,id=masterKey0,format=raw,file=/var/lib/libvirt/qemu/domain-17-one-24992/master-key.aes +-machine pc-i440fx-2.8,accel=kvm,usb=off,dump-guest-core=off + +-m 2048 +-realtime mlock=off +-smp 1,sockets=1,cores=1,threads=1 +-uuid 732e08c7-84f8-4d43-9571-263db4f80080 -no-user-config \ + -nodefaults +-chardev socket,id=charmonitor,path=/var/lib/libvirt/qemu/domain-17-one-24992/monitor.sock,server,nowait +-mon chardev=charmonitor,id=monitor,mode=control +-rtc base=utc -no-shutdown +-boot strict=on +-device piix3-usb-uhci,id=usb,bus=pci.0,addr=0x1.0x2 +-drive file=rbd:ssd/one-292-24992-0:id=libvirt:auth_supported=cephx\;none:mon_host=ceph1\:6789\;ceph2\:6789\;ceph3\:6789,format=raw,if=none,id=drive-virtio-disk0,cache=none +-device virtio-blk-pci,scsi=off,bus=pci.0,addr=0x4,drive=drive-virtio-disk0,id=virtio-disk0,bootindex=1 +-drive file=/var/lib/one//datastores/104/24992/disk.1,format=raw,if=none,id=drive-ide0-0-0,readonly=on +-device ide-cd,bus=ide.0,unit=0,drive=drive-ide0-0-0,id=ide0-0-0 +-netdev tap,fd=36,id=hostnet0,vhost=on,vhostfd=38 +-device virtio-net-pci,netdev=hostnet0,id=net0,mac=02:00:f0:a9:c4:4e,bus=pci.0,addr=0x3 +-vnc [::]:4414 -device cirrus-vga,id=video0,bus=pci.0,addr=0x2 -device virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x5 -msg timestamp=on From b9c9a5e0eca41009f5461ff601b11a3bf2151ea7 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 10 Jan 2020 23:55:21 +0100 Subject: [PATCH 144/284] add working network --- uncloud/hack/hackcloud/.gitignore | 3 +++ uncloud/hack/hackcloud/foo | 2 -- uncloud/hack/hackcloud/ifup.sh | 1 + uncloud/hack/hackcloud/net.sh | 3 +++ uncloud/hack/hackcloud/radvd.conf | 13 +++++++++++++ uncloud/hack/hackcloud/radvd.sh | 3 +++ 6 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 uncloud/hack/hackcloud/.gitignore delete mode 100644 uncloud/hack/hackcloud/foo create mode 100644 uncloud/hack/hackcloud/radvd.conf create mode 100644 uncloud/hack/hackcloud/radvd.sh diff --git a/uncloud/hack/hackcloud/.gitignore b/uncloud/hack/hackcloud/.gitignore new file mode 100644 index 0000000..0ad647b --- /dev/null +++ b/uncloud/hack/hackcloud/.gitignore @@ -0,0 +1,3 @@ +*.iso +radvdpid +foo diff --git a/uncloud/hack/hackcloud/foo b/uncloud/hack/hackcloud/foo deleted file mode 100644 index 1033abf..0000000 --- a/uncloud/hack/hackcloud/foo +++ /dev/null @@ -1,2 +0,0 @@ -tap0 -tap0 diff --git a/uncloud/hack/hackcloud/ifup.sh b/uncloud/hack/hackcloud/ifup.sh index 95bfe5a..99e8690 100755 --- a/uncloud/hack/hackcloud/ifup.sh +++ b/uncloud/hack/hackcloud/ifup.sh @@ -6,3 +6,4 @@ dev=$1; shift # bridge is setup from outside ip link set dev "$dev" master ${bridge} +ip link set dev "$dev" up diff --git a/uncloud/hack/hackcloud/net.sh b/uncloud/hack/hackcloud/net.sh index e56822f..7d4b88f 100755 --- a/uncloud/hack/hackcloud/net.sh +++ b/uncloud/hack/hackcloud/net.sh @@ -5,6 +5,7 @@ dev=wlp2s0 dev=wlp0s20f3 dev=wlan0 +ip=2a0a:e5c1:111:888::42/64 vxlandev=vxlan${netid} bridgedev=br${netid} @@ -19,3 +20,5 @@ ip link set ${vxlandev} up ip link add ${bridgedev} type bridge ip link set ${bridgedev} up + +ip addr add ${ip} dev ${bridgedev} diff --git a/uncloud/hack/hackcloud/radvd.conf b/uncloud/hack/hackcloud/radvd.conf new file mode 100644 index 0000000..3d8ce4d --- /dev/null +++ b/uncloud/hack/hackcloud/radvd.conf @@ -0,0 +1,13 @@ +interface br100 +{ + AdvSendAdvert on; + MinRtrAdvInterval 3; + MaxRtrAdvInterval 5; + AdvDefaultLifetime 3600; + + prefix 2a0a:e5c1:111:888::/64 { + }; + + RDNSS 2a0a:e5c0::3 2a0a:e5c0::4 { AdvRDNSSLifetime 6000; }; + DNSSL place7.ungleich.ch { AdvDNSSLLifetime 6000; } ; +}; diff --git a/uncloud/hack/hackcloud/radvd.sh b/uncloud/hack/hackcloud/radvd.sh new file mode 100644 index 0000000..9d0e7d1 --- /dev/null +++ b/uncloud/hack/hackcloud/radvd.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +radvd -C ./radvd.conf -n -p ./radvdpid From 3825c7c210fefa9112a0efdeea3f8c2e20ad7bec Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 11 Jan 2020 00:23:55 +0100 Subject: [PATCH 145/284] Add vxlan into the bridge --- uncloud/hack/hackcloud/net.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/uncloud/hack/hackcloud/net.sh b/uncloud/hack/hackcloud/net.sh index 7d4b88f..0a96fe1 100755 --- a/uncloud/hack/hackcloud/net.sh +++ b/uncloud/hack/hackcloud/net.sh @@ -18,7 +18,10 @@ ip -6 link add ${vxlandev} type vxlan \ ip link set ${vxlandev} up + ip link add ${bridgedev} type bridge ip link set ${bridgedev} up +ip link set ${vxlandev} master ${bridgedev} up + ip addr add ${ip} dev ${bridgedev} From 23d805f04fc8349d5e503414174819f7d26a1f78 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 11 Jan 2020 00:24:17 +0100 Subject: [PATCH 146/284] ++stuff --- uncloud/hack/hackcloud/net.sh | 4 ++-- uncloud/hack/uncloud-run-vm | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/uncloud/hack/hackcloud/net.sh b/uncloud/hack/hackcloud/net.sh index 7d4b88f..4cb6498 100755 --- a/uncloud/hack/hackcloud/net.sh +++ b/uncloud/hack/hackcloud/net.sh @@ -3,9 +3,9 @@ netid=100 dev=wlp2s0 dev=wlp0s20f3 -dev=wlan0 +#dev=wlan0 -ip=2a0a:e5c1:111:888::42/64 +ip=2a0a:e5c1:111:888::48/64 vxlandev=vxlan${netid} bridgedev=br${netid} diff --git a/uncloud/hack/uncloud-run-vm b/uncloud/hack/uncloud-run-vm index 1af2037..33e5860 100644 --- a/uncloud/hack/uncloud-run-vm +++ b/uncloud/hack/uncloud-run-vm @@ -18,4 +18,8 @@ qemu-system-x86_64 \ -smp 2,sockets=2,cores=1,threads=1 \ -device virtio-net-pci,netdev=net0,mac=$macaddress \ -netdev tap,id=net0,ifname=${netname},script=no,downscript=no \ - -vnc [::]:5900 + -vnc [::]:0 + +# To be changed: +# -vnc to unix path +# or -spice From c1cabb7220e208ed2ba976c695f786a23edac185 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 11 Jan 2020 02:42:04 +0100 Subject: [PATCH 147/284] add working nft --- uncloud/hack/hackcloud/nftrules | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 uncloud/hack/hackcloud/nftrules diff --git a/uncloud/hack/hackcloud/nftrules b/uncloud/hack/hackcloud/nftrules new file mode 100644 index 0000000..661d91f --- /dev/null +++ b/uncloud/hack/hackcloud/nftrules @@ -0,0 +1,32 @@ +flush ruleset + +table bridge filter { + chain prerouting { + type filter hook prerouting priority 0; + policy accept; + ibrname br100 jump netpublic + } + chain netpublic { + + iifname tap1 jump vm1 + + icmpv6 type {nd-router-solicit, nd-router-advert, + nd-neighbor-solicit, nd-neighbor-advert, nd-redirect } log + + } + chain vm1 { + ether saddr != 02:00:f0:a9:c4:4e drop + } +} + +table ip6 filter { + chain forward { + type filter hook forward priority 0; + + # policy drop; + + ct state established,related accept; + + } + +} From 029ef36d62d0fd4767cbfc9da7a721f3973f1bd2 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 11 Jan 2020 15:54:19 +0100 Subject: [PATCH 148/284] net +debug --- uncloud/hack/hackcloud/net.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/uncloud/hack/hackcloud/net.sh b/uncloud/hack/hackcloud/net.sh index 5c7a6f5..4e2bfa1 100755 --- a/uncloud/hack/hackcloud/net.sh +++ b/uncloud/hack/hackcloud/net.sh @@ -1,5 +1,7 @@ #!/bin/sh +set -x + netid=100 dev=wlp2s0 dev=wlp0s20f3 From 3b68a589d47f40b8d6a0e4020e2a4e0254741424 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 11 Jan 2020 16:17:35 +0100 Subject: [PATCH 149/284] cleanup vm.sh --- uncloud/hack/hackcloud/vm.sh | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/uncloud/hack/hackcloud/vm.sh b/uncloud/hack/hackcloud/vm.sh index 2a8b794..2cc0480 100755 --- a/uncloud/hack/hackcloud/vm.sh +++ b/uncloud/hack/hackcloud/vm.sh @@ -21,28 +21,3 @@ $qemu -name uc${vmid} \ -drive file=alpine-virt-3.11.2-x86_64.iso,media=cdrom \ -netdev tap,id=netmain,script=./ifup.sh \ -device virtio-net-pci,netdev=netmain,id=net0,mac=02:00:f0:a9:c4:4e - - - -exit 0 - --S -object secret,id=masterKey0,format=raw,file=/var/lib/libvirt/qemu/domain-17-one-24992/master-key.aes --machine pc-i440fx-2.8,accel=kvm,usb=off,dump-guest-core=off - --m 2048 --realtime mlock=off --smp 1,sockets=1,cores=1,threads=1 --uuid 732e08c7-84f8-4d43-9571-263db4f80080 -no-user-config \ - -nodefaults --chardev socket,id=charmonitor,path=/var/lib/libvirt/qemu/domain-17-one-24992/monitor.sock,server,nowait --mon chardev=charmonitor,id=monitor,mode=control --rtc base=utc -no-shutdown --boot strict=on --device piix3-usb-uhci,id=usb,bus=pci.0,addr=0x1.0x2 --drive file=rbd:ssd/one-292-24992-0:id=libvirt:auth_supported=cephx\;none:mon_host=ceph1\:6789\;ceph2\:6789\;ceph3\:6789,format=raw,if=none,id=drive-virtio-disk0,cache=none --device virtio-blk-pci,scsi=off,bus=pci.0,addr=0x4,drive=drive-virtio-disk0,id=virtio-disk0,bootindex=1 --drive file=/var/lib/one//datastores/104/24992/disk.1,format=raw,if=none,id=drive-ide0-0-0,readonly=on --device ide-cd,bus=ide.0,unit=0,drive=drive-ide0-0-0,id=ide0-0-0 --netdev tap,fd=36,id=hostnet0,vhost=on,vhostfd=38 --device virtio-net-pci,netdev=hostnet0,id=net0,mac=02:00:f0:a9:c4:4e,bus=pci.0,addr=0x3 --vnc [::]:4414 -device cirrus-vga,id=video0,bus=pci.0,addr=0x2 -device virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x5 -msg timestamp=on From 708e3ebb971745a3ea8e6ad81179365829bc7398 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 11 Jan 2020 16:20:29 +0100 Subject: [PATCH 150/284] cleanup ifup.sh --- uncloud/hack/hackcloud/ifup.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/uncloud/hack/hackcloud/ifup.sh b/uncloud/hack/hackcloud/ifup.sh index 99e8690..e0a3ca0 100755 --- a/uncloud/hack/hackcloud/ifup.sh +++ b/uncloud/hack/hackcloud/ifup.sh @@ -1,7 +1,5 @@ #!/bin/sh -echo $@ >> foo - dev=$1; shift # bridge is setup from outside From 8544df8bad808910f31eaa85bf0808f056f09512 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 11 Jan 2020 16:36:41 +0100 Subject: [PATCH 151/284] don't use tcg --- uncloud/hack/hackcloud/vm.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud/hack/hackcloud/vm.sh b/uncloud/hack/hackcloud/vm.sh index 2cc0480..dfef8cc 100755 --- a/uncloud/hack/hackcloud/vm.sh +++ b/uncloud/hack/hackcloud/vm.sh @@ -5,7 +5,7 @@ vmid=$1; shift qemu=/usr/bin/qemu-system-x86_64 accel=kvm -accel=tcg +#accel=tcg memory=1024 cores=2 From c6b7152464f8a68fc2e9e7383c5044fef3a84005 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 11 Jan 2020 21:21:30 +0100 Subject: [PATCH 152/284] update nftrules example --- uncloud/hack/hackcloud/nftrules | 72 ++++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/uncloud/hack/hackcloud/nftrules b/uncloud/hack/hackcloud/nftrules index 661d91f..035b3a8 100644 --- a/uncloud/hack/hackcloud/nftrules +++ b/uncloud/hack/hackcloud/nftrules @@ -1,32 +1,64 @@ flush ruleset table bridge filter { - chain prerouting { - type filter hook prerouting priority 0; - policy accept; - ibrname br100 jump netpublic - } - chain netpublic { + chain prerouting { + type filter hook prerouting priority 0; + policy accept; - iifname tap1 jump vm1 + ibrname br100 jump netpublic + } - icmpv6 type {nd-router-solicit, nd-router-advert, - nd-neighbor-solicit, nd-neighbor-advert, nd-redirect } log + chain netpublic { + iifname vxlan100 jump from_uncloud - } - chain vm1 { - ether saddr != 02:00:f0:a9:c4:4e drop - } + # Default blocks: router advertisements, dhcpv6, dhcpv4 + icmpv6 type nd-router-advert drop + ip6 version 6 udp sport 547 drop + ip version 4 udp sport 67 drop + + # Individual blocks + iifname tap1 jump vm1 + } + + chain vm1 { + ether saddr != 02:00:f0:a9:c4:4e drop + ip6 saddr != 2a0a:e5c1:111:888:0:f0ff:fea9:c44e drop + } + + chain from_uncloud { + accept + } } -table ip6 filter { - chain forward { - type filter hook forward priority 0; +# table ip6 filter { +# chain forward { +# type filter hook forward priority 0; - # policy drop; +# # policy drop; - ct state established,related accept; +# ct state established,related accept; - } +# } -} +# } + +# table ip filter { +# chain input { +# type filter hook input priority filter; policy drop; +# iif "lo" accept +# icmp type { echo-reply, destination-unreachable, source-quench, redirect, echo-request, router-advertisement, router-solicitation, time-exceeded, parameter-problem, timestamp-request, timestamp-reply, info-request, info-reply, address-mask-request, address-mask-reply } accept +# ct state established,related accept +# tcp dport { 22 } accept +# log prefix "firewall-ipv4: " +# udp sport 67 drop +# } + +# chain forward { +# type filter hook forward priority filter; policy drop; +# log prefix "firewall-ipv4: " +# } + +# chain output { +# type filter hook output priority filter; policy accept; +# } +# } From 6d51e2a8c4cfe5bb6695e390fd55414657beb8bc Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 12 Jan 2020 00:32:17 +0100 Subject: [PATCH 153/284] [metadata] change default port to 1234 --- uncloud/metadata/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/uncloud/metadata/main.py b/uncloud/metadata/main.py index ccda60e..c47364e 100644 --- a/uncloud/metadata/main.py +++ b/uncloud/metadata/main.py @@ -13,8 +13,10 @@ api = Api(app) app.logger.handlers.clear() +DEFAULT_PORT=1234 + arg_parser = argparse.ArgumentParser('metadata', add_help=False) -arg_parser.add_argument('--port', '-p', default=80, help='By default bind to port 80') +arg_parser.add_argument('--port', '-p', default=DEFAULT_PORT, help='By default bind to port {}'.format(DEFAULT_PORT)) @app.errorhandler(Exception) From aaf29adcbb6c1aea64952099d14c1aaceb644e43 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 12 Jan 2020 00:41:31 +0100 Subject: [PATCH 154/284] + mac prefix --- uncloud/hack/hackcloud/mac-prefix | 1 + 1 file changed, 1 insertion(+) create mode 100644 uncloud/hack/hackcloud/mac-prefix diff --git a/uncloud/hack/hackcloud/mac-prefix b/uncloud/hack/hackcloud/mac-prefix new file mode 100644 index 0000000..5084a2f --- /dev/null +++ b/uncloud/hack/hackcloud/mac-prefix @@ -0,0 +1 @@ +02:00 From b017df4879a4daf8e1f4542fd9f7fcf0aab7fc40 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 12 Jan 2020 13:20:38 +0100 Subject: [PATCH 155/284] ignore iso, update nft rules --- .gitignore | 2 ++ uncloud/hack/hackcloud/nftrules | 57 +++++++-------------------------- 2 files changed, 14 insertions(+), 45 deletions(-) diff --git a/.gitignore b/.gitignore index 5c55899..6f0d9df 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ uncloud/version.py build/ venv/ dist/ + +*.iso diff --git a/uncloud/hack/hackcloud/nftrules b/uncloud/hack/hackcloud/nftrules index 035b3a8..636c63d 100644 --- a/uncloud/hack/hackcloud/nftrules +++ b/uncloud/hack/hackcloud/nftrules @@ -5,60 +5,27 @@ table bridge filter { type filter hook prerouting priority 0; policy accept; - ibrname br100 jump netpublic + ibrname br100 jump br100 } - chain netpublic { - iifname vxlan100 jump from_uncloud + chain br100 { + # Allow all incoming traffic from outside + iifname vxlan100 accept # Default blocks: router advertisements, dhcpv6, dhcpv4 icmpv6 type nd-router-advert drop ip6 version 6 udp sport 547 drop ip version 4 udp sport 67 drop - # Individual blocks - iifname tap1 jump vm1 + jump br100_vmlist + drop } + chain br100_vmlist { + # VM1 + iifname tap1 ether saddr 02:00:f0:a9:c4:4e ip6 saddr 2a0a:e5c1:111:888:0:f0ff:fea9:c44e accept - chain vm1 { - ether saddr != 02:00:f0:a9:c4:4e drop - ip6 saddr != 2a0a:e5c1:111:888:0:f0ff:fea9:c44e drop - } - - chain from_uncloud { - accept + # VM2 + iifname v343a-0 ether saddr 02:00:f0:a9:c4:4f ip6 saddr 2a0a:e5c1:111:888:0:f0ff:fea9:c44f accept + iifname v343a-0 ether saddr 02:00:f0:a9:c4:4f ip6 saddr 2a0a:e5c1:111:1234::/64 accept } } - -# table ip6 filter { -# chain forward { -# type filter hook forward priority 0; - -# # policy drop; - -# ct state established,related accept; - -# } - -# } - -# table ip filter { -# chain input { -# type filter hook input priority filter; policy drop; -# iif "lo" accept -# icmp type { echo-reply, destination-unreachable, source-quench, redirect, echo-request, router-advertisement, router-solicitation, time-exceeded, parameter-problem, timestamp-request, timestamp-reply, info-request, info-reply, address-mask-request, address-mask-reply } accept -# ct state established,related accept -# tcp dport { 22 } accept -# log prefix "firewall-ipv4: " -# udp sport 67 drop -# } - -# chain forward { -# type filter hook forward priority filter; policy drop; -# log prefix "firewall-ipv4: " -# } - -# chain output { -# type filter hook output priority filter; policy accept; -# } -# } From 64ab011299fa230399cba5c401962974a4b6c069 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 12 Jan 2020 13:41:54 +0100 Subject: [PATCH 156/284] import mac.py from cinv --- uncloud/hack/hackcloud/mac-gen.py | 171 ++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 uncloud/hack/hackcloud/mac-gen.py diff --git a/uncloud/hack/hackcloud/mac-gen.py b/uncloud/hack/hackcloud/mac-gen.py new file mode 100644 index 0000000..9f23854 --- /dev/null +++ b/uncloud/hack/hackcloud/mac-gen.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# 2012 Nico Schottelius (nico-cinv at schottelius.org) +# +# This file is part of cinv. +# +# cinv is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# cinv is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with cinv. If not, see . +# +# + +import argparse +import logging +import os.path +import os +import re + +import cinv +from cinv import fsproperty + +log = logging.getLogger(__name__) + +class Error(cinv.Error): + pass + + +class Mac(object): + + def __init__(self): + self.base_dir = self.get_base_dir() + + _prefix = fsproperty.FileStringProperty(lambda obj: os.path.join(obj.base_dir, "prefix")) + free = fsproperty.FileListProperty(lambda obj: os.path.join(obj.base_dir, "free")) + last = fsproperty.FileStringProperty(lambda obj: os.path.join(obj.base_dir, "last")) + + def _init_base_dir(self): + try: + os.makedirs(self.base_dir, exist_ok=True) + except OSError as e: + raise Error(e) + + @staticmethod + def validate_mac(mac): + if not re.match(r'([0-9A-F]{2}[-:]){5}[0-9A-F]{2}$', mac, re.I): + raise Error("Not a valid mac address: %s" % mac) + + def free_append(self, mac): + if mac in self.free: + raise Error("Mac already in free database: %s" % mac) + + self._init_base_dir() + self.free.append(mac) + + @staticmethod + def get_base_dir(): + return cinv.get_base_dir("db/mac") + + @classmethod + def exists(cls): + return os.path.exists(cls.get_base_dir()) + + def get_next(self): + self._init_base_dir() + + if self.free: + return self.free.pop() + + if not self.prefix: + raise Error("Cannot generate address without prefix - use prefix-set") + + if self.last: + suffix = re.search(r'([0-9A-F]{2}[-:]){2}[0-9A-F]{2}$', self.last, re.I) + last_number_hex = "0x%s" % suffix.group().replace(":", "") + last_number = int(last_number_hex, 16) + + if last_number == int('0xffffff', 16): + raise Error("Exhausted all possible mac addresses - try to free some") + + next_number = last_number + 1 + else: + next_number = 0 + + next_number_hex = "%0.6x" % next_number + next_suffix = "%s:%s:%s" % (next_number_hex[0:2], next_number_hex[2:4], next_number_hex[4:6]) + + next_mac = "%s:%s" % (self.prefix, next_suffix) + + self.last = next_mac + + return next_mac + + + @property + def prefix(self): + return self._prefix + + @prefix.setter + def prefix(self, prefix): + if not re.match(r'([0-9A-F]{2}[-:]){2}[0-9A-F]{2}$', prefix, re.I): + raise Error("Wrong mac address format - use 00:11:22") + + self._init_base_dir() + self._prefix = prefix + + @classmethod + def commandline_generate(cls, args): + mac = Mac() + print(mac.get_next()) + + @classmethod + def commandline_free_add(cls, args): + mac = Mac() + mac.validate_mac(args.address) + mac.free_append(args.address) + + @classmethod + def commandline_free_list(cls, args): + mac = Mac() + for mac in mac.free: + print(mac) + + @classmethod + def commandline_prefix_set(cls, args): + mac = Mac() + mac.prefix = args.prefix + + @classmethod + def commandline_prefix_get(cls, args): + mac = cls() + print(mac.prefix) + + @classmethod + def commandline_add(cls, args): + host = cls(fqdn=args.fqdn) + host.host_type = args.type + + @classmethod + def commandline_args(cls, parent_parser, parents): + """Add us to the parent parser and add all parents to our parsers""" + + parser = {} + parser['sub'] = parent_parser.add_subparsers(title="Mac Commands") + + parser['free-add'] = parser['sub'].add_parser('free-add', parents=parents) + parser['free-add'].add_argument('address', help='Address to add to free database') + parser['free-add'].set_defaults(func=cls.commandline_free_add) + + parser['free-list'] = parser['sub'].add_parser('free-list', parents=parents, + help="List free mac addresses") + parser['free-list'].set_defaults(func=cls.commandline_free_list) + + parser['generate'] = parser['sub'].add_parser('generate', parents=parents) + parser['generate'].set_defaults(func=cls.commandline_generate) + + parser['prefix-get'] = parser['sub'].add_parser('prefix-get', parents=parents) + parser['prefix-get'].set_defaults(func=cls.commandline_prefix_get) + + parser['prefix-set'] = parser['sub'].add_parser('prefix-set', parents=parents) + parser['prefix-set'].add_argument('prefix', help="3 Byte address prefix (f.i. '00:16:3e')") + parser['prefix-set'].set_defaults(func=cls.commandline_prefix_set) From 53c6a14d608c5c7d564a14d4ca31eac9c413c930 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 12 Jan 2020 14:03:04 +0100 Subject: [PATCH 157/284] mac: begin to downstrip --- uncloud/hack/hackcloud/mac-gen.py | 56 ++++++++++++------------------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/uncloud/hack/hackcloud/mac-gen.py b/uncloud/hack/hackcloud/mac-gen.py index 9f23854..f2a5db0 100644 --- a/uncloud/hack/hackcloud/mac-gen.py +++ b/uncloud/hack/hackcloud/mac-gen.py @@ -26,29 +26,32 @@ import os.path import os import re -import cinv -from cinv import fsproperty - log = logging.getLogger(__name__) -class Error(cinv.Error): +class Error(Exception): pass class Mac(object): - def __init__(self): - self.base_dir = self.get_base_dir() + self.base_dir = "." - _prefix = fsproperty.FileStringProperty(lambda obj: os.path.join(obj.base_dir, "prefix")) - free = fsproperty.FileListProperty(lambda obj: os.path.join(obj.base_dir, "free")) - last = fsproperty.FileStringProperty(lambda obj: os.path.join(obj.base_dir, "last")) + self._prefix = "02:00" + + self.free = self.read_file("mac-free") + self.last = self.read_file("mac-last") + + def read_file(self, filename): + fname = os.path.join(self.base_dir, filename) + ret = [] - def _init_base_dir(self): try: - os.makedirs(self.base_dir, exist_ok=True) - except OSError as e: - raise Error(e) + with open(fname, "r") as fd: + ret = fd.readlines() + except Exception as e: + pass + + return ret @staticmethod def validate_mac(mac): @@ -146,26 +149,11 @@ class Mac(object): host.host_type = args.type @classmethod - def commandline_args(cls, parent_parser, parents): - """Add us to the parent parser and add all parents to our parsers""" + def commandline(cls): + pass - parser = {} - parser['sub'] = parent_parser.add_subparsers(title="Mac Commands") - parser['free-add'] = parser['sub'].add_parser('free-add', parents=parents) - parser['free-add'].add_argument('address', help='Address to add to free database') - parser['free-add'].set_defaults(func=cls.commandline_free_add) - - parser['free-list'] = parser['sub'].add_parser('free-list', parents=parents, - help="List free mac addresses") - parser['free-list'].set_defaults(func=cls.commandline_free_list) - - parser['generate'] = parser['sub'].add_parser('generate', parents=parents) - parser['generate'].set_defaults(func=cls.commandline_generate) - - parser['prefix-get'] = parser['sub'].add_parser('prefix-get', parents=parents) - parser['prefix-get'].set_defaults(func=cls.commandline_prefix_get) - - parser['prefix-set'] = parser['sub'].add_parser('prefix-set', parents=parents) - parser['prefix-set'].add_argument('prefix', help="3 Byte address prefix (f.i. '00:16:3e')") - parser['prefix-set'].set_defaults(func=cls.commandline_prefix_set) +if __name__ == '__main__': + m = Mac() + m.commandline() + print(m.free) From 94dad7c9b6ae8bd5e5bf4b035b582ce7d4a44a01 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 12 Jan 2020 14:35:59 +0100 Subject: [PATCH 158/284] Add script to generate mac addresses --- uncloud/hack/hackcloud/mac-gen.py | 96 +++++++++---------------------- uncloud/hack/hackcloud/mac-last | 1 + 2 files changed, 28 insertions(+), 69 deletions(-) mode change 100644 => 100755 uncloud/hack/hackcloud/mac-gen.py create mode 100644 uncloud/hack/hackcloud/mac-last diff --git a/uncloud/hack/hackcloud/mac-gen.py b/uncloud/hack/hackcloud/mac-gen.py old mode 100644 new mode 100755 index f2a5db0..e2b4bc5 --- a/uncloud/hack/hackcloud/mac-gen.py +++ b/uncloud/hack/hackcloud/mac-gen.py @@ -36,7 +36,8 @@ class Mac(object): def __init__(self): self.base_dir = "." - self._prefix = "02:00" + self.prefix = 0x002000000000 + #self.prefix = "{:012x}".format(self._prefix) self.free = self.read_file("mac-free") self.last = self.read_file("mac-last") @@ -53,6 +54,11 @@ class Mac(object): return ret + def append_to_file(self, text, filename): + fname = os.path.join(self.base_dir, filename) + with open(fname, "a+") as fd: + fd.write("{}\n".format(text)) + @staticmethod def validate_mac(mac): if not re.match(r'([0-9A-F]{2}[-:]){5}[0-9A-F]{2}$', mac, re.I): @@ -62,30 +68,24 @@ class Mac(object): if mac in self.free: raise Error("Mac already in free database: %s" % mac) - self._init_base_dir() - self.free.append(mac) + self.append_to_file(mac, "mac-free") + self.free = self.read_file("mac-free") + @staticmethod - def get_base_dir(): - return cinv.get_base_dir("db/mac") + def int_to_mac(number): + b = number.to_bytes(6, byteorder="big") + return ':'.join(format(s, '02x') for s in b) - @classmethod - def exists(cls): - return os.path.exists(cls.get_base_dir()) + def getnext(self): +# if self.free: +# return self.free.pop() - def get_next(self): - self._init_base_dir() - - if self.free: - return self.free.pop() - - if not self.prefix: - raise Error("Cannot generate address without prefix - use prefix-set") +# if not self.prefix: +# raise Error("Cannot generate address without prefix - use prefix-set") if self.last: - suffix = re.search(r'([0-9A-F]{2}[-:]){2}[0-9A-F]{2}$', self.last, re.I) - last_number_hex = "0x%s" % suffix.group().replace(":", "") - last_number = int(last_number_hex, 16) + last_number = int(self.last[0], 16) if last_number == int('0xffffff', 16): raise Error("Exhausted all possible mac addresses - try to free some") @@ -94,60 +94,16 @@ class Mac(object): else: next_number = 0 - next_number_hex = "%0.6x" % next_number - next_suffix = "%s:%s:%s" % (next_number_hex[0:2], next_number_hex[2:4], next_number_hex[4:6]) + next_number_string = "{:012x}".format(next_number) - next_mac = "%s:%s" % (self.prefix, next_suffix) + next_mac_number = self.prefix + next_number + next_mac = self.int_to_mac(next_mac_number) - self.last = next_mac + with open(os.path.join(self.base_dir, "mac-last"), "w+") as fd: + fd.write("{}\n".format(next_number_string)) return next_mac - - @property - def prefix(self): - return self._prefix - - @prefix.setter - def prefix(self, prefix): - if not re.match(r'([0-9A-F]{2}[-:]){2}[0-9A-F]{2}$', prefix, re.I): - raise Error("Wrong mac address format - use 00:11:22") - - self._init_base_dir() - self._prefix = prefix - - @classmethod - def commandline_generate(cls, args): - mac = Mac() - print(mac.get_next()) - - @classmethod - def commandline_free_add(cls, args): - mac = Mac() - mac.validate_mac(args.address) - mac.free_append(args.address) - - @classmethod - def commandline_free_list(cls, args): - mac = Mac() - for mac in mac.free: - print(mac) - - @classmethod - def commandline_prefix_set(cls, args): - mac = Mac() - mac.prefix = args.prefix - - @classmethod - def commandline_prefix_get(cls, args): - mac = cls() - print(mac.prefix) - - @classmethod - def commandline_add(cls, args): - host = cls(fqdn=args.fqdn) - host.host_type = args.type - @classmethod def commandline(cls): pass @@ -156,4 +112,6 @@ class Mac(object): if __name__ == '__main__': m = Mac() m.commandline() - print(m.free) + # print(m.free) + #print(m.last) + print(m.getnext()) diff --git a/uncloud/hack/hackcloud/mac-last b/uncloud/hack/hackcloud/mac-last new file mode 100644 index 0000000..df32b47 --- /dev/null +++ b/uncloud/hack/hackcloud/mac-last @@ -0,0 +1 @@ +000000000006 From 3188787c2a9f4ad9bb7c4b0f66818d21ea5d8579 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 12 Jan 2020 14:38:01 +0100 Subject: [PATCH 159/284] ++mac change --- uncloud/hack/hackcloud/mac-last | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud/hack/hackcloud/mac-last b/uncloud/hack/hackcloud/mac-last index df32b47..90a4264 100644 --- a/uncloud/hack/hackcloud/mac-last +++ b/uncloud/hack/hackcloud/mac-last @@ -1 +1 @@ -000000000006 +000000000245 From 02526baaf979783e3f1ed661f8f16a256b3b9f5a Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 12 Jan 2020 14:43:06 +0100 Subject: [PATCH 160/284] add ifdown support --- uncloud/hack/hackcloud/ifdown.sh | 3 +++ uncloud/hack/hackcloud/vm.sh | 11 +++++------ 2 files changed, 8 insertions(+), 6 deletions(-) create mode 100644 uncloud/hack/hackcloud/ifdown.sh diff --git a/uncloud/hack/hackcloud/ifdown.sh b/uncloud/hack/hackcloud/ifdown.sh new file mode 100644 index 0000000..70fe1db --- /dev/null +++ b/uncloud/hack/hackcloud/ifdown.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +echo $@! diff --git a/uncloud/hack/hackcloud/vm.sh b/uncloud/hack/hackcloud/vm.sh index dfef8cc..56956ea 100755 --- a/uncloud/hack/hackcloud/vm.sh +++ b/uncloud/hack/hackcloud/vm.sh @@ -1,7 +1,5 @@ #!/bin/sh -vmid=$1; shift - qemu=/usr/bin/qemu-system-x86_64 accel=kvm @@ -9,15 +7,16 @@ accel=kvm memory=1024 cores=2 -uuid=732e08c7-84f8-4d43-9571-263db4f80080 +uuid=$(uuidgen) +mac=$(./mac-gen.py) export bridge=br100 -$qemu -name uc${vmid} \ +$qemu -name "uncloud-!${uuid}" \ -machine pc,accel=${accel} \ -m ${memory} \ -smp ${cores} \ -uuid ${uuid} \ -drive file=alpine-virt-3.11.2-x86_64.iso,media=cdrom \ - -netdev tap,id=netmain,script=./ifup.sh \ - -device virtio-net-pci,netdev=netmain,id=net0,mac=02:00:f0:a9:c4:4e + -netdev tap,id=netmain,script=./ifup.sh,downscript=./ifdown.sh \ + -device virtio-net-pci,netdev=netmain,id=net0,mac=${mac} From e6d22a73c5efbe9c91d316cf1c7ee576bd239e92 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 12 Jan 2020 14:44:53 +0100 Subject: [PATCH 161/284] ++ cleanup --- uncloud/hack/hackcloud/ifdown.sh | 2 +- uncloud/hack/hackcloud/mac-last | 2 +- uncloud/hack/hackcloud/vm.sh | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) mode change 100644 => 100755 uncloud/hack/hackcloud/ifdown.sh diff --git a/uncloud/hack/hackcloud/ifdown.sh b/uncloud/hack/hackcloud/ifdown.sh old mode 100644 new mode 100755 index 70fe1db..5753099 --- a/uncloud/hack/hackcloud/ifdown.sh +++ b/uncloud/hack/hackcloud/ifdown.sh @@ -1,3 +1,3 @@ #!/bin/sh -echo $@! +echo $@ diff --git a/uncloud/hack/hackcloud/mac-last b/uncloud/hack/hackcloud/mac-last index 90a4264..59f6410 100644 --- a/uncloud/hack/hackcloud/mac-last +++ b/uncloud/hack/hackcloud/mac-last @@ -1 +1 @@ -000000000245 +000000000251 diff --git a/uncloud/hack/hackcloud/vm.sh b/uncloud/hack/hackcloud/vm.sh index 56956ea..a0e111b 100755 --- a/uncloud/hack/hackcloud/vm.sh +++ b/uncloud/hack/hackcloud/vm.sh @@ -12,7 +12,8 @@ mac=$(./mac-gen.py) export bridge=br100 -$qemu -name "uncloud-!${uuid}" \ +set -x +$qemu -name "uncloud-${uuid}" \ -machine pc,accel=${accel} \ -m ${memory} \ -smp ${cores} \ From c3b42aabc626f4cca1617dfcba9b52fbf587502a Mon Sep 17 00:00:00 2001 From: Ahmed Bilal <49-ahmedbilal@users.noreply.code.ungleich.ch> Date: Mon, 13 Jan 2020 05:57:41 +0100 Subject: [PATCH 162/284] Added --conf-dir, --etcd-{host,port,ca_cert,cert_cert,cert_key} parameters to cli and settings is now accessbile through uncloud.shared.shared.settings --- scripts/uncloud | 59 ++++++++++++++++++----------- uncloud/api/common_fields.py | 7 +--- uncloud/api/create_image_store.py | 3 +- uncloud/api/helper.py | 18 ++++----- uncloud/api/main.py | 61 +++++++++++++++--------------- uncloud/api/schemas.py | 13 +++---- uncloud/cli/helper.py | 29 ++++++-------- uncloud/common/cli.py | 26 +++++++++++++ uncloud/common/settings.py | 19 ++++++---- uncloud/common/shared.py | 22 +++++------ uncloud/common/storage_handlers.py | 15 ++++---- uncloud/configure/main.py | 11 +++--- uncloud/filescanner/main.py | 7 ++-- uncloud/host/main.py | 11 +++--- uncloud/host/virtualmachine.py | 11 +++--- uncloud/imagescanner/main.py | 7 ++-- uncloud/metadata/main.py | 3 +- uncloud/scheduler/helper.py | 3 +- uncloud/scheduler/main.py | 5 +-- 19 files changed, 176 insertions(+), 154 deletions(-) create mode 100644 uncloud/common/cli.py diff --git a/scripts/uncloud b/scripts/uncloud index a6e61aa..1a6483b 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -3,14 +3,13 @@ import logging import sys import importlib import argparse +import os +from etcd3.exceptions import ConnectionFailedError + +from uncloud.common import settings from uncloud import UncloudException - -# the components that use etcd -ETCD_COMPONENTS = ['api', 'scheduler', 'host', 'filescanner', 'imagescanner', 'metadata', 'configure'] - -ALL_COMPONENTS = ETCD_COMPONENTS.copy() -ALL_COMPONENTS.append('cli') +from uncloud.common.cli import resolve_otp_credentials def exception_hook(exc_type, exc_value, exc_traceback): @@ -22,6 +21,13 @@ def exception_hook(exc_type, exc_value, exc_traceback): sys.excepthook = exception_hook +# the components that use etcd +ETCD_COMPONENTS = ['api', 'scheduler', 'host', 'filescanner', 'imagescanner', 'metadata', 'configure'] + +ALL_COMPONENTS = ETCD_COMPONENTS.copy() +ALL_COMPONENTS.append('cli') + + if __name__ == '__main__': # Setting up root logger logger = logging.getLogger() @@ -31,15 +37,13 @@ if __name__ == '__main__': subparsers = arg_parser.add_subparsers(dest='command') parent_parser = argparse.ArgumentParser(add_help=False) - parent_parser.add_argument('--debug', '-d', - action='store_true', - default=False, + parent_parser.add_argument('--debug', '-d', action='store_true', default=False, help='More verbose logging') - parent_parser.add_argument('--conf-dir', '-c', - help='Configuration directory') + parent_parser.add_argument('--conf-dir', '-c', help='Configuration directory', + default=os.path.expanduser('~/uncloud')) etcd_parser = argparse.ArgumentParser(add_help=False) - etcd_parser.add_argument('--etcd-host') + etcd_parser.add_argument('--etcd-host', dest='etcd_url') etcd_parser.add_argument('--etcd-port') etcd_parser.add_argument('--etcd-ca-cert', help='CA that signed the etcd certificate') etcd_parser.add_argument('--etcd-cert-cert', help='Path to client certificate') @@ -54,25 +58,36 @@ if __name__ == '__main__': else: subparsers.add_parser(name=parser.prog, parents=[parser, parent_parser]) - args = arg_parser.parse_args() - if not args.command: + arguments = vars(arg_parser.parse_args()) + etcd_arguments = [key for key, value in arguments.items() if key.startswith('etcd_') and value] + etcd_arguments = { + 'etcd': { + key.replace('etcd_', ''): arguments[key] + for key in etcd_arguments + } + } + if not arguments['command']: arg_parser.print_help() else: - arguments = vars(args) + # Initializing Settings and resolving otp_credentials + # It is neccessary to resolve_otp_credentials after argument parsing is done because + # previously we were reading config file which was fixed to ~/uncloud/uncloud.conf and + # providing the default values for --name, --realm and --seed arguments from the values + # we read from file. But, now we are asking user about where the config file lives. So, + # to providing default value is not possible before parsing arguments. So, we are doing + # it after.. + settings.settings = settings.Settings(arguments['conf_dir'], seed_value=etcd_arguments) + resolve_otp_credentials(arguments) + name = arguments.pop('command') mod = importlib.import_module('uncloud.{}.main'.format(name)) main = getattr(mod, 'main') - # If the component requires etcd3, we import it and catch the - # etcd3.exceptions.ConnectionFailedError - if name in ETCD_COMPONENTS: - import etcd3 - try: main(arguments) except UncloudException as err: logger.error(err) - sys.exit(1) + except ConnectionFailedError: + logger.error('Cannot connect to etcd') except Exception as err: logger.exception(err) - sys.exit(1) diff --git a/uncloud/api/common_fields.py b/uncloud/api/common_fields.py index d1fcb64..ba9fb37 100755 --- a/uncloud/api/common_fields.py +++ b/uncloud/api/common_fields.py @@ -1,7 +1,6 @@ import os from uncloud.common.shared import shared -from uncloud.common.settings import settings class Optional: @@ -54,9 +53,7 @@ class VmUUIDField(Field): def vm_uuid_validation(self): r = shared.etcd_client.get( - os.path.join(settings["etcd"]["vm_prefix"], self.uuid) + os.path.join(shared.settings["etcd"]["vm_prefix"], self.uuid) ) if not r: - self.add_error( - "VM with uuid {} does not exists".format(self.uuid) - ) + self.add_error("VM with uuid {} does not exists".format(self.uuid)) diff --git a/uncloud/api/create_image_store.py b/uncloud/api/create_image_store.py index 1040e97..90e0f92 100755 --- a/uncloud/api/create_image_store.py +++ b/uncloud/api/create_image_store.py @@ -4,7 +4,6 @@ import os from uuid import uuid4 from uncloud.common.shared import shared -from uncloud.common.settings import settings data = { 'is_public': True, @@ -15,6 +14,6 @@ data = { } shared.etcd_client.put( - os.path.join(settings['etcd']['image_store_prefix'], uuid4().hex), + os.path.join(shared.settings['etcd']['image_store_prefix'], uuid4().hex), json.dumps(data), ) diff --git a/uncloud/api/helper.py b/uncloud/api/helper.py index 0805280..8ceb3a6 100755 --- a/uncloud/api/helper.py +++ b/uncloud/api/helper.py @@ -7,7 +7,6 @@ import requests from pyotp import TOTP from uncloud.common.shared import shared -from uncloud.common.settings import settings logger = logging.getLogger(__name__) @@ -15,9 +14,9 @@ logger = logging.getLogger(__name__) def check_otp(name, realm, token): try: data = { - "auth_name": settings["otp"]["auth_name"], - "auth_token": TOTP(settings["otp"]["auth_seed"]).now(), - "auth_realm": settings["otp"]["auth_realm"], + "auth_name": shared.settings["otp"]["auth_name"], + "auth_token": TOTP(shared.settings["otp"]["auth_seed"]).now(), + "auth_realm": shared.settings["otp"]["auth_realm"], "name": name, "realm": realm, "token": token, @@ -25,13 +24,13 @@ def check_otp(name, realm, token): except binascii.Error as err: logger.error( "Cannot compute OTP for seed: {}".format( - settings["otp"]["auth_seed"] + shared.settings["otp"]["auth_seed"] ) ) return 400 response = requests.post( - settings["otp"]["verification_controller_url"], json=data + shared.settings["otp"]["verification_controller_url"], json=data ) return response.status_code @@ -87,7 +86,7 @@ def resolve_image_name(name, etcd_client): ) images = etcd_client.get_prefix( - settings["etcd"]["image_prefix"], value_in_json=True + shared.settings["etcd"]["image_prefix"], value_in_json=True ) # Try to find image with name == image_name and store_name == store_name @@ -111,9 +110,7 @@ def random_bytes(num=6): return [random.randrange(256) for _ in range(num)] -def generate_mac( - uaa=False, multicast=False, oui=None, separator=":", byte_fmt="%02x" -): +def generate_mac(uaa=False, multicast=False, oui=None, separator=":", byte_fmt="%02x"): mac = random_bytes() if oui: if type(oui) == str: @@ -148,3 +145,4 @@ def mac2ipv6(mac, prefix): lower_part = ipaddress.IPv6Address(":".join(ipv6_parts)) prefix = ipaddress.IPv6Address(prefix) return str(prefix + int(lower_part)) + diff --git a/uncloud/api/main.py b/uncloud/api/main.py index 34e1dd1..73e8e21 100644 --- a/uncloud/api/main.py +++ b/uncloud/api/main.py @@ -15,9 +15,8 @@ from uncloud.common.shared import shared from uncloud.common import counters from uncloud.common.vm import VMStatus from uncloud.common.request import RequestEntry, RequestType -from uncloud.common.settings import settings -from . import schemas -from .helper import generate_mac, mac2ipv6 +from uncloud.api import schemas +from uncloud.api.helper import generate_mac, mac2ipv6 from uncloud import UncloudException logger = logging.getLogger(__name__) @@ -50,7 +49,7 @@ class CreateVM(Resource): validator = schemas.CreateVMSchema(data) if validator.is_valid(): vm_uuid = uuid4().hex - vm_key = join_path(settings['etcd']['vm_prefix'], vm_uuid) + vm_key = join_path(shared.settings['etcd']['vm_prefix'], vm_uuid) specs = { 'cpu': validator.specs['cpu'], 'ram': validator.specs['ram'], @@ -60,7 +59,7 @@ class CreateVM(Resource): macs = [generate_mac() for _ in range(len(data['network']))] tap_ids = [ counters.increment_etcd_counter( - shared.etcd_client, settings['etcd']['tap_counter'] + shared.etcd_client, shared.settings['etcd']['tap_counter'] ) for _ in range(len(data['network'])) ] @@ -84,7 +83,7 @@ class CreateVM(Resource): r = RequestEntry.from_scratch( type=RequestType.ScheduleVM, uuid=vm_uuid, - request_prefix=settings['etcd']['request_prefix'], + request_prefix=shared.settings['etcd']['request_prefix'], ) shared.request_pool.put(r) @@ -99,7 +98,7 @@ class VmStatus(Resource): validator = schemas.VMStatusSchema(data) if validator.is_valid(): vm = shared.vm_pool.get( - join_path(settings['etcd']['vm_prefix'], data['uuid']) + join_path(shared.settings['etcd']['vm_prefix'], data['uuid']) ) vm_value = vm.value.copy() vm_value['ip'] = [] @@ -107,7 +106,7 @@ class VmStatus(Resource): network_name, mac, tap = network_mac_and_tap network = shared.etcd_client.get( join_path( - settings['etcd']['network_prefix'], + shared.settings['etcd']['network_prefix'], data['name'], network_name, ), @@ -130,7 +129,7 @@ class CreateImage(Resource): validator = schemas.CreateImageSchema(data) if validator.is_valid(): file_entry = shared.etcd_client.get( - join_path(settings['etcd']['file_prefix'], data['uuid']) + join_path(shared.settings['etcd']['file_prefix'], data['uuid']) ) file_entry_value = json.loads(file_entry.value) @@ -144,7 +143,7 @@ class CreateImage(Resource): } shared.etcd_client.put( join_path( - settings['etcd']['image_prefix'], data['uuid'] + shared.settings['etcd']['image_prefix'], data['uuid'] ), json.dumps(image_entry_json), ) @@ -157,7 +156,7 @@ class ListPublicImages(Resource): @staticmethod def get(): images = shared.etcd_client.get_prefix( - settings['etcd']['image_prefix'], value_in_json=True + shared.settings['etcd']['image_prefix'], value_in_json=True ) r = {'images': []} for image in images: @@ -178,7 +177,7 @@ class VMAction(Resource): if validator.is_valid(): vm_entry = shared.vm_pool.get( - join_path(settings['etcd']['vm_prefix'], data['uuid']) + join_path(shared.settings['etcd']['vm_prefix'], data['uuid']) ) action = data['action'] @@ -208,7 +207,7 @@ class VMAction(Resource): type='{}VM'.format(action.title()), uuid=data['uuid'], hostname=vm_entry.hostname, - request_prefix=settings['etcd']['request_prefix'], + request_prefix=shared.settings['etcd']['request_prefix'], ) shared.request_pool.put(r) return ( @@ -231,10 +230,10 @@ class VMMigration(Resource): type=RequestType.InitVMMigration, uuid=vm.uuid, hostname=join_path( - settings['etcd']['host_prefix'], + shared.settings['etcd']['host_prefix'], validator.destination.value, ), - request_prefix=settings['etcd']['request_prefix'], + request_prefix=shared.settings['etcd']['request_prefix'], ) shared.request_pool.put(r) @@ -254,7 +253,7 @@ class ListUserVM(Resource): if validator.is_valid(): vms = shared.etcd_client.get_prefix( - settings['etcd']['vm_prefix'], value_in_json=True + shared.settings['etcd']['vm_prefix'], value_in_json=True ) return_vms = [] user_vms = filter( @@ -287,7 +286,7 @@ class ListUserFiles(Resource): if validator.is_valid(): files = shared.etcd_client.get_prefix( - settings['etcd']['file_prefix'], value_in_json=True + shared.settings['etcd']['file_prefix'], value_in_json=True ) return_files = [] user_files = [f for f in files if f.value['owner'] == data['name']] @@ -312,7 +311,7 @@ class CreateHost(Resource): validator = schemas.CreateHostSchema(data) if validator.is_valid(): host_key = join_path( - settings['etcd']['host_prefix'], uuid4().hex + shared.settings['etcd']['host_prefix'], uuid4().hex ) host_entry = { 'specs': data['specs'], @@ -354,7 +353,7 @@ class GetSSHKeys(Resource): # {user_prefix}/{realm}/{name}/key/ etcd_key = join_path( - settings['etcd']['user_prefix'], + shared.settings['etcd']['user_prefix'], data['realm'], data['name'], 'key', @@ -372,7 +371,7 @@ class GetSSHKeys(Resource): # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - settings['etcd']['user_prefix'], + shared.settings['etcd']['user_prefix'], data['realm'], data['name'], 'key', @@ -405,7 +404,7 @@ class AddSSHKey(Resource): # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - settings['etcd']['user_prefix'], + shared.settings['etcd']['user_prefix'], data['realm'], data['name'], 'key', @@ -439,7 +438,7 @@ class RemoveSSHKey(Resource): # {user_prefix}/{realm}/{name}/key/{key_name} etcd_key = join_path( - settings['etcd']['user_prefix'], + shared.settings['etcd']['user_prefix'], data['realm'], data['name'], 'key', @@ -471,23 +470,23 @@ class CreateNetwork(Resource): network_entry = { 'id': counters.increment_etcd_counter( - shared.etcd_client, settings['etcd']['vxlan_counter'] + shared.etcd_client, shared.settings['etcd']['vxlan_counter'] ), 'type': data['type'], } if validator.user.value: try: nb = pynetbox.api( - url=settings['netbox']['url'], - token=settings['netbox']['token'], + url=shared.settings['netbox']['url'], + token=shared.settings['netbox']['token'], ) nb_prefix = nb.ipam.prefixes.get( - prefix=settings['network']['prefix'] + prefix=shared.settings['network']['prefix'] ) prefix = nb_prefix.available_prefixes.create( data={ 'prefix_length': int( - settings['network']['prefix_length'] + shared.settings['network']['prefix_length'] ), 'description': '{}\'s network "{}"'.format( data['name'], data['network_name'] @@ -506,7 +505,7 @@ class CreateNetwork(Resource): network_entry['ipv6'] = 'fd00::/64' network_key = join_path( - settings['etcd']['network_prefix'], + shared.settings['etcd']['network_prefix'], data['name'], data['network_name'], ) @@ -526,7 +525,7 @@ class ListUserNetwork(Resource): if validator.is_valid(): prefix = join_path( - settings['etcd']['network_prefix'], data['name'] + shared.settings['etcd']['network_prefix'], data['name'] ) networks = shared.etcd_client.get_prefix( prefix, value_in_json=True @@ -570,7 +569,7 @@ def main(arguments): try: image_stores = list( shared.etcd_client.get_prefix( - settings['etcd']['image_store_prefix'], value_in_json=True + shared.settings['etcd']['image_store_prefix'], value_in_json=True ) ) except KeyError: @@ -590,7 +589,7 @@ def main(arguments): # shared.etcd_client.put( # join_path( - # settings['etcd']['image_store_prefix'], uuid4().hex + # shared.settings['etcd']['image_store_prefix'], uuid4().hex # ), # json.dumps(data), # ) diff --git a/uncloud/api/schemas.py b/uncloud/api/schemas.py index e4de9a8..87f20c9 100755 --- a/uncloud/api/schemas.py +++ b/uncloud/api/schemas.py @@ -22,7 +22,6 @@ import bitmath from uncloud.common.host import HostStatus from uncloud.common.vm import VMStatus from uncloud.common.shared import shared -from uncloud.common.settings import settings from . import helper, logger from .common_fields import Field, VmUUIDField from .helper import check_otp, resolve_vm_name @@ -112,7 +111,7 @@ class CreateImageSchema(BaseSchema): def file_uuid_validation(self): file_entry = shared.etcd_client.get( os.path.join( - settings["etcd"]["file_prefix"], self.uuid.value + shared.shared.shared.shared.shared.settings["etcd"]["file_prefix"], self.uuid.value ) ) if file_entry is None: @@ -125,7 +124,7 @@ class CreateImageSchema(BaseSchema): def image_store_name_validation(self): image_stores = list( shared.etcd_client.get_prefix( - settings["etcd"]["image_store_prefix"] + shared.shared.shared.shared.shared.settings["etcd"]["image_store_prefix"] ) ) @@ -283,7 +282,7 @@ class CreateVMSchema(OTPSchema): for net in _network: network = shared.etcd_client.get( os.path.join( - settings["etcd"]["network_prefix"], + shared.shared.shared.shared.shared.settings["etcd"]["network_prefix"], self.name.value, net, ), @@ -488,7 +487,7 @@ class VmMigrationSchema(OTPSchema): self.add_error("Can't migrate non-running VM") if vm.hostname == os.path.join( - settings["etcd"]["host_prefix"], self.destination.value + shared.shared.shared.shared.shared.settings["etcd"]["host_prefix"], self.destination.value ): self.add_error( "Destination host couldn't be same as Source Host" @@ -539,9 +538,7 @@ class CreateNetwork(OTPSchema): super().__init__(data, fields=fields) def network_name_validation(self): - print(self.name.value, self.network_name.value) - key = os.path.join(settings["etcd"]["network_prefix"], self.name.value, self.network_name.value) - print(key) + key = os.path.join(shared.shared.shared.shared.shared.settings["etcd"]["network_prefix"], self.name.value, self.network_name.value) network = shared.etcd_client.get(key, value_in_json=True) if network: self.add_error( diff --git a/uncloud/cli/helper.py b/uncloud/cli/helper.py index 3c63073..51a4355 100644 --- a/uncloud/cli/helper.py +++ b/uncloud/cli/helper.py @@ -5,23 +5,14 @@ import binascii from pyotp import TOTP from os.path import join as join_path -from uncloud.common.settings import settings +from uncloud.common.shared import shared def get_otp_parser(): otp_parser = argparse.ArgumentParser('otp') - try: - name = settings['client']['name'] - realm = settings['client']['realm'] - seed = settings['client']['seed'] - except Exception: - otp_parser.add_argument('--name', required=True) - otp_parser.add_argument('--realm', required=True) - otp_parser.add_argument('--seed', required=True, type=get_token, dest='token', metavar='SEED') - else: - otp_parser.add_argument('--name', default=name) - otp_parser.add_argument('--realm', default=realm) - otp_parser.add_argument('--seed', default=seed, type=get_token, dest='token', metavar='SEED') + otp_parser.add_argument('--name') + otp_parser.add_argument('--realm') + otp_parser.add_argument('--seed', type=get_token, dest='token', metavar='SEED') return otp_parser @@ -34,11 +25,15 @@ def load_dump_pretty(content): def make_request(*args, data=None, request_method=requests.post): - r = request_method(join_path(settings['client']['api_server'], *args), json=data) try: - print(load_dump_pretty(r.content)) - except Exception: - print('Error occurred while getting output from api server.') + r = request_method(join_path(shared.settings['client']['api_server'], *args), json=data) + except requests.exceptions.RequestException: + print('Error occurred while connecting to API server.') + else: + try: + print(load_dump_pretty(r.content)) + except Exception: + print('Error occurred while getting output from api server.') def get_token(seed): diff --git a/uncloud/common/cli.py b/uncloud/common/cli.py new file mode 100644 index 0000000..3d3c248 --- /dev/null +++ b/uncloud/common/cli.py @@ -0,0 +1,26 @@ +from uncloud.common.shared import shared +from pyotp import TOTP + + +def get_token(seed): + if seed is not None: + try: + token = TOTP(seed).now() + except Exception: + raise Exception('Invalid seed') + else: + return token + + +def resolve_otp_credentials(kwargs): + d = { + 'name': shared.settings['client']['name'], + 'realm': shared.settings['client']['realm'], + 'token': get_token(shared.settings['client']['seed']) + } + + for k, v in d.items(): + if k in kwargs and kwargs[k] is None: + kwargs.update({k: v}) + + return d diff --git a/uncloud/common/settings.py b/uncloud/common/settings.py index 0d524a7..8503f42 100644 --- a/uncloud/common/settings.py +++ b/uncloud/common/settings.py @@ -8,6 +8,7 @@ from uncloud.common.etcd_wrapper import Etcd3Wrapper from os.path import join as join_path logger = logging.getLogger(__name__) +settings = None class CustomConfigParser(configparser.RawConfigParser): @@ -25,9 +26,8 @@ class CustomConfigParser(configparser.RawConfigParser): class Settings(object): - def __init__(self): + def __init__(self, conf_dir, seed_value=None): conf_name = 'uncloud.conf' - conf_dir = os.environ.get('UCLOUD_CONF_DIR', os.path.expanduser('~/uncloud/')) self.config_file = join_path(conf_dir, conf_name) # this is used to cache config from etcd for 1 minutes. Without this we @@ -38,15 +38,19 @@ class Settings(object): self.config_parser.add_section('etcd') self.config_parser.set('etcd', 'base_prefix', '/') - try: + if os.access(self.config_file, os.R_OK): self.config_parser.read(self.config_file) - except Exception as err: - logger.error('%s', err) - + else: + raise FileNotFoundError('Config file %s not found!', self.config_file) self.config_key = join_path(self['etcd']['base_prefix'] + 'uncloud/config/') self.read_internal_values() + if seed_value is None: + seed_value = dict() + + self.config_parser.read_dict(seed_value) + def get_etcd_client(self): args = tuple() try: @@ -128,4 +132,5 @@ class Settings(object): return self.config_parser[key] -settings = Settings() +def get_settings(): + return settings diff --git a/uncloud/common/shared.py b/uncloud/common/shared.py index 918dd0c..aea7cbc 100644 --- a/uncloud/common/shared.py +++ b/uncloud/common/shared.py @@ -1,34 +1,34 @@ -from uncloud.common.settings import settings +from uncloud.common.settings import get_settings from uncloud.common.vm import VmPool from uncloud.common.host import HostPool from uncloud.common.request import RequestPool -from uncloud.common.storage_handlers import get_storage_handler +import uncloud.common.storage_handlers as storage_handlers class Shared: + @property + def settings(self): + return get_settings() + @property def etcd_client(self): - return settings.get_etcd_client() + return self.settings.get_etcd_client() @property def host_pool(self): - return HostPool( - self.etcd_client, settings["etcd"]["host_prefix"] - ) + return HostPool(self.etcd_client, self.settings["etcd"]["host_prefix"]) @property def vm_pool(self): - return VmPool(self.etcd_client, settings["etcd"]["vm_prefix"]) + return VmPool(self.etcd_client, self.settings["etcd"]["vm_prefix"]) @property def request_pool(self): - return RequestPool( - self.etcd_client, settings["etcd"]["request_prefix"] - ) + return RequestPool(self.etcd_client, self.settings["etcd"]["request_prefix"]) @property def storage_handler(self): - return get_storage_handler() + return storage_handlers.get_storage_handler() shared = Shared() diff --git a/uncloud/common/storage_handlers.py b/uncloud/common/storage_handlers.py index 6f9b29e..58c2dc2 100644 --- a/uncloud/common/storage_handlers.py +++ b/uncloud/common/storage_handlers.py @@ -6,8 +6,7 @@ import stat from abc import ABC from . import logger from os.path import join as join_path - -from uncloud.common.settings import settings as config +import uncloud.common.shared as shared class ImageStorageHandler(ABC): @@ -193,16 +192,16 @@ class CEPHBasedImageStorageHandler(ImageStorageHandler): def get_storage_handler(): - __storage_backend = config["storage"]["storage_backend"] + __storage_backend = shared.shared.settings["storage"]["storage_backend"] if __storage_backend == "filesystem": return FileSystemBasedImageStorageHandler( - vm_base=config["storage"]["vm_dir"], - image_base=config["storage"]["image_dir"], + vm_base=shared.shared.settings["storage"]["vm_dir"], + image_base=shared.shared.settings["storage"]["image_dir"], ) elif __storage_backend == "ceph": return CEPHBasedImageStorageHandler( - vm_base=config["storage"]["ceph_vm_pool"], - image_base=config["storage"]["ceph_image_pool"], + vm_base=shared.shared.settings["storage"]["ceph_vm_pool"], + image_base=shared.shared.settings["storage"]["ceph_image_pool"], ) else: - raise Exception("Unknown Image Storage Handler") + raise Exception("Unknown Image Storage Handler") \ No newline at end of file diff --git a/uncloud/configure/main.py b/uncloud/configure/main.py index e190460..87f5752 100644 --- a/uncloud/configure/main.py +++ b/uncloud/configure/main.py @@ -1,7 +1,6 @@ import os import argparse -from uncloud.common.settings import settings from uncloud.common.shared import shared arg_parser = argparse.ArgumentParser('configure', add_help=False) @@ -40,19 +39,19 @@ ceph_storage_parser.add_argument('--ceph-image-pool', required=True) def update_config(section, kwargs): - uncloud_config = shared.etcd_client.get(settings.config_key, value_in_json=True) + uncloud_config = shared.etcd_client.get(shared.settings.config_key, value_in_json=True) if not uncloud_config: uncloud_config = {} else: uncloud_config = uncloud_config.value uncloud_config[section] = kwargs - shared.etcd_client.put(settings.config_key, uncloud_config, value_in_json=True) + shared.etcd_client.put(shared.settings.config_key, uncloud_config, value_in_json=True) -def main(**kwargs): - subcommand = kwargs.pop('subcommand') +def main(arguments): + subcommand = arguments['subcommand'] if not subcommand: arg_parser.print_help() else: - update_config(subcommand, kwargs) + update_config(subcommand, arguments) diff --git a/uncloud/filescanner/main.py b/uncloud/filescanner/main.py index c5660dd..046f915 100755 --- a/uncloud/filescanner/main.py +++ b/uncloud/filescanner/main.py @@ -9,7 +9,6 @@ import bitmath from uuid import uuid4 from . import logger -from uncloud.common.settings import settings from uncloud.common.shared import shared arg_parser = argparse.ArgumentParser('filescanner', add_help=False) @@ -53,7 +52,7 @@ def track_file(file, base_dir, host): file_path = file_path.relative_to(owner) creation_date = time.ctime(os.stat(file_str).st_ctime) - entry_key = os.path.join(settings['etcd']['file_prefix'], str(uuid4())) + entry_key = os.path.join(shared.settings['etcd']['file_prefix'], str(uuid4())) entry_value = { 'filename': str(file_path), 'owner': owner, @@ -70,7 +69,7 @@ def track_file(file, base_dir, host): def main(arguments): hostname = arguments['hostname'] - base_dir = settings['storage']['file_dir'] + base_dir = shared.settings['storage']['file_dir'] # Recursively Get All Files and Folder below BASE_DIR files = glob.glob('{}/**'.format(base_dir), recursive=True) files = [pathlib.Path(f) for f in files if pathlib.Path(f).is_file()] @@ -78,7 +77,7 @@ def main(arguments): # Files that are already tracked tracked_files = [ pathlib.Path(os.path.join(base_dir, f.value['owner'], f.value['filename'])) - for f in shared.etcd_client.get_prefix(settings['etcd']['file_prefix'], value_in_json=True) + for f in shared.etcd_client.get_prefix(shared.settings['etcd']['file_prefix'], value_in_json=True) if f.value['host'] == hostname ] untracked_files = set(files) - set(tracked_files) diff --git a/uncloud/host/main.py b/uncloud/host/main.py index ccffd77..f680991 100755 --- a/uncloud/host/main.py +++ b/uncloud/host/main.py @@ -6,7 +6,6 @@ from uuid import uuid4 from uncloud.common.request import RequestEntry, RequestType from uncloud.common.shared import shared -from uncloud.common.settings import settings from uncloud.common.vm import VMStatus from uncloud.vmm import VMM from os.path import join as join_path @@ -36,7 +35,7 @@ def maintenance(host): if vmm.is_running(vm_uuid) and vmm.get_status(vm_uuid) == 'running': logger.debug('VM {} is running on {}'.format(vm_uuid, host)) vm = shared.vm_pool.get( - join_path(settings['etcd']['vm_prefix'], vm_uuid) + join_path(shared.settings['etcd']['vm_prefix'], vm_uuid) ) vm.status = VMStatus.running vm.vnc_socket = vmm.get_vnc(vm_uuid) @@ -52,7 +51,7 @@ def main(arguments): # Does not yet exist, create it if not host: host_key = join_path( - settings['etcd']['host_prefix'], uuid4().hex + shared.settings['etcd']['host_prefix'], uuid4().hex ) host_entry = { 'specs': '', @@ -80,9 +79,9 @@ def main(arguments): # get prefix until either success or deamon death comes. while True: for events_iterator in [ - shared.etcd_client.get_prefix(settings['etcd']['request_prefix'], value_in_json=True, + shared.etcd_client.get_prefix(shared.settings['etcd']['request_prefix'], value_in_json=True, raise_exception=False), - shared.etcd_client.watch_prefix(settings['etcd']['request_prefix'], value_in_json=True, + shared.etcd_client.watch_prefix(shared.settings['etcd']['request_prefix'], value_in_json=True, raise_exception=False) ]: for request_event in events_iterator: @@ -95,7 +94,7 @@ def main(arguments): shared.request_pool.client.client.delete(request_event.key) vm_entry = shared.etcd_client.get( - join_path(settings['etcd']['vm_prefix'], request_event.uuid) + join_path(shared.settings['etcd']['vm_prefix'], request_event.uuid) ) logger.debug('VM hostname: {}'.format(vm_entry.value)) diff --git a/uncloud/host/virtualmachine.py b/uncloud/host/virtualmachine.py index 2f6a5e3..a592efc 100755 --- a/uncloud/host/virtualmachine.py +++ b/uncloud/host/virtualmachine.py @@ -17,7 +17,6 @@ from uncloud.common.network import create_dev, delete_network_interface from uncloud.common.schemas import VMSchema, NetworkSchema from uncloud.host import logger from uncloud.common.shared import shared -from uncloud.common.settings import settings from uncloud.vmm import VMM from marshmallow import ValidationError @@ -91,7 +90,7 @@ class VM: self.vmm.socket_dir, self.uuid ), destination_host_key=destination_host_key, # Where source host transfer VM - request_prefix=settings["etcd"]["request_prefix"], + request_prefix=shared.settings["etcd"]["request_prefix"], ) shared.request_pool.put(r) else: @@ -119,7 +118,7 @@ class VM: network_name, mac, tap = network_mac_and_tap _key = os.path.join( - settings["etcd"]["network_prefix"], + shared.settings["etcd"]["network_prefix"], self.vm["owner"], network_name, ) @@ -133,13 +132,13 @@ class VM: if network["type"] == "vxlan": tap = create_vxlan_br_tap( _id=network["id"], - _dev=settings["network"]["vxlan_phy_dev"], + _dev=shared.settings["network"]["vxlan_phy_dev"], tap_id=tap, ip=network["ipv6"], ) all_networks = shared.etcd_client.get_prefix( - settings["etcd"]["network_prefix"], + shared.settings["etcd"]["network_prefix"], value_in_json=True, ) @@ -229,7 +228,7 @@ class VM: def resolve_network(network_name, network_owner): network = shared.etcd_client.get( join_path( - settings["etcd"]["network_prefix"], + shared.settings["etcd"]["network_prefix"], network_owner, network_name, ), diff --git a/uncloud/imagescanner/main.py b/uncloud/imagescanner/main.py index 1803213..ee9da2e 100755 --- a/uncloud/imagescanner/main.py +++ b/uncloud/imagescanner/main.py @@ -4,7 +4,6 @@ import argparse import subprocess as sp from os.path import join as join_path -from uncloud.common.settings import settings from uncloud.common.shared import shared from uncloud.imagescanner import logger @@ -33,7 +32,7 @@ def qemu_img_type(path): def main(arguments): # We want to get images entries that requests images to be created images = shared.etcd_client.get_prefix( - settings["etcd"]["image_prefix"], value_in_json=True + shared.settings["etcd"]["image_prefix"], value_in_json=True ) images_to_be_created = list( filter(lambda im: im.value["status"] == "TO_BE_CREATED", images) @@ -46,13 +45,13 @@ def main(arguments): image_filename = image.value["filename"] image_store_name = image.value["store_name"] image_full_path = join_path( - settings["storage"]["file_dir"], + shared.settings["storage"]["file_dir"], image_owner, image_filename, ) image_stores = shared.etcd_client.get_prefix( - settings["etcd"]["image_store_prefix"], + shared.settings["etcd"]["image_store_prefix"], value_in_json=True, ) user_image_store = next( diff --git a/uncloud/metadata/main.py b/uncloud/metadata/main.py index c47364e..374260e 100644 --- a/uncloud/metadata/main.py +++ b/uncloud/metadata/main.py @@ -5,7 +5,6 @@ from flask import Flask, request from flask_restful import Resource, Api from werkzeug.exceptions import HTTPException -from uncloud.common.settings import settings from uncloud.common.shared import shared app = Flask(__name__) @@ -74,7 +73,7 @@ class Root(Resource): ) else: etcd_key = os.path.join( - settings["etcd"]["user_prefix"], + shared.settings["etcd"]["user_prefix"], data.value["owner_realm"], data.value["owner"], "key", diff --git a/uncloud/scheduler/helper.py b/uncloud/scheduler/helper.py index 108d126..79db322 100755 --- a/uncloud/scheduler/helper.py +++ b/uncloud/scheduler/helper.py @@ -7,7 +7,6 @@ from uncloud.common.host import HostStatus from uncloud.common.request import RequestEntry, RequestType from uncloud.common.vm import VMStatus from uncloud.common.shared import shared -from uncloud.common.settings import settings def accumulated_specs(vms_specs): @@ -130,7 +129,7 @@ def assign_host(vm): type=RequestType.StartVM, uuid=vm.uuid, hostname=vm.hostname, - request_prefix=settings["etcd"]["request_prefix"], + request_prefix=shared.settings["etcd"]["request_prefix"], ) shared.request_pool.put(r) diff --git a/uncloud/scheduler/main.py b/uncloud/scheduler/main.py index c25700b..38c07bf 100755 --- a/uncloud/scheduler/main.py +++ b/uncloud/scheduler/main.py @@ -6,7 +6,6 @@ import argparse -from uncloud.common.settings import settings from uncloud.common.request import RequestEntry, RequestType from uncloud.common.shared import shared from uncloud.scheduler import logger @@ -24,9 +23,9 @@ def main(arguments): # get prefix until either success or deamon death comes. while True: for request_iterator in [ - shared.etcd_client.get_prefix(settings['etcd']['request_prefix'], value_in_json=True, + shared.etcd_client.get_prefix(shared.settings['etcd']['request_prefix'], value_in_json=True, raise_exception=False), - shared.etcd_client.watch_prefix(settings['etcd']['request_prefix'], value_in_json=True, + shared.etcd_client.watch_prefix(shared.settings['etcd']['request_prefix'], value_in_json=True, raise_exception=False), ]: for request_event in request_iterator: From 091131d3509ecae41aedacc3788e7c166f623b99 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Mon, 13 Jan 2020 11:52:40 +0100 Subject: [PATCH 163/284] dummy --- uncloud/hack/hackcloud/mac-last | 2 +- uncloud/vmm/__init__.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/uncloud/hack/hackcloud/mac-last b/uncloud/hack/hackcloud/mac-last index 59f6410..8c5f254 100644 --- a/uncloud/hack/hackcloud/mac-last +++ b/uncloud/hack/hackcloud/mac-last @@ -1 +1 @@ -000000000251 +000000000252 diff --git a/uncloud/vmm/__init__.py b/uncloud/vmm/__init__.py index 4c893f6..719bdbe 100644 --- a/uncloud/vmm/__init__.py +++ b/uncloud/vmm/__init__.py @@ -100,9 +100,9 @@ class TransferVM(Process): class VMM: # Virtual Machine Manager def __init__( - self, - qemu_path="/usr/bin/qemu-system-x86_64", - vmm_backend=os.path.expanduser("~/uncloud/vmm/"), + self, + qemu_path="/usr/bin/qemu-system-x86_64", + vmm_backend=os.path.expanduser("~/uncloud/vmm/"), ): self.qemu_path = qemu_path self.vmm_backend = vmm_backend From 10c8dc85ba58398203046c4c303689c8d3e45bd5 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Mon, 13 Jan 2020 12:14:30 +0100 Subject: [PATCH 164/284] Begin hacky database handling --- uncloud/hack/hackcloud/db.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 uncloud/hack/hackcloud/db.py diff --git a/uncloud/hack/hackcloud/db.py b/uncloud/hack/hackcloud/db.py new file mode 100644 index 0000000..3d885e9 --- /dev/null +++ b/uncloud/hack/hackcloud/db.py @@ -0,0 +1,17 @@ +import etcd3 + + +if __name__ == '__main__': + endpoints = [ "https://etcd1.ungleich.ch:2379", + "!https://etcd2.ungleich.ch:2379", + "https://etcd3.ungleich.ch:2379" ] + + clients = [] + + for endpoint in endpoints: + client = etcd3.client(host=endpoint, + ca_cert="/home/nico/vcs/ungleich-dot-cdist/files/etcd/ca.pem", + cert_cert="/home/nico/vcs/ungleich-dot-cdist/files/etcd/nico.pem", + cert_key="/home/nico/vcs/ungleich-dot-cdist/files/etcd/nico-key.pem") + + clients.append(client) From 9f02b31b1b2035cda0fb663781add044990d942b Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Mon, 13 Jan 2020 12:54:02 +0100 Subject: [PATCH 165/284] Add hacky etcd client --- uncloud/hack/hackcloud/etcd-client.sh | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 uncloud/hack/hackcloud/etcd-client.sh diff --git a/uncloud/hack/hackcloud/etcd-client.sh b/uncloud/hack/hackcloud/etcd-client.sh new file mode 100644 index 0000000..ab102a5 --- /dev/null +++ b/uncloud/hack/hackcloud/etcd-client.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +etcdctl --cert=$HOME/vcs/ungleich-dot-cdist/files/etcd/nico.pem \ + --key=/home/nico/vcs/ungleich-dot-cdist/files/etcd/nico-key.pem \ + --cacert=$HOME/vcs/ungleich-dot-cdist/files/etcd/ca.pem \ + --endpoints https://etcd1.ungleich.ch:2379,https://etcd2.ungleich.ch:2379,https://etcd3.ungleich.ch:2379 "$@" From b96e56b453bff53898bb105560881c88ffd63218 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 14 Jan 2020 11:05:42 +0100 Subject: [PATCH 166/284] Begin to integrate hack into the main script --- scripts/uncloud | 3 +- uncloud/hack/__init__.py | 0 uncloud/hack/hackcloud/__init__.py | 1 + uncloud/hack/hackcloud/db.py | 55 ++++++++++++++++++++++++------ uncloud/hack/hackcloud/vm.py | 53 ++++++++++++++++++++++++++++ uncloud/hack/hackcloud/vm.sh | 6 ++++ uncloud/hack/main.py | 10 ++++++ 7 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 uncloud/hack/__init__.py create mode 100644 uncloud/hack/hackcloud/__init__.py create mode 100755 uncloud/hack/hackcloud/vm.py create mode 100644 uncloud/hack/main.py diff --git a/scripts/uncloud b/scripts/uncloud index 1a6483b..70cb535 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -22,7 +22,8 @@ def exception_hook(exc_type, exc_value, exc_traceback): sys.excepthook = exception_hook # the components that use etcd -ETCD_COMPONENTS = ['api', 'scheduler', 'host', 'filescanner', 'imagescanner', 'metadata', 'configure'] +ETCD_COMPONENTS = ['api', 'scheduler', 'host', 'filescanner', + 'imagescanner', 'metadata', 'configure', 'hack'] ALL_COMPONENTS = ETCD_COMPONENTS.copy() ALL_COMPONENTS.append('cli') diff --git a/uncloud/hack/__init__.py b/uncloud/hack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/hack/hackcloud/__init__.py b/uncloud/hack/hackcloud/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/uncloud/hack/hackcloud/__init__.py @@ -0,0 +1 @@ + diff --git a/uncloud/hack/hackcloud/db.py b/uncloud/hack/hackcloud/db.py index 3d885e9..0e6bd0a 100644 --- a/uncloud/hack/hackcloud/db.py +++ b/uncloud/hack/hackcloud/db.py @@ -1,17 +1,52 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# 2020 Nico Schottelius (nico.schottelius at ungleich.ch) +# +# This file is part of uncloud. +# +# uncloud is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# uncloud is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with uncloud. If not, see . +# +# + import etcd3 +class DB(object): + def __init__(self, urls): + self.urls = urls + self.prefix = "/nicohack/" + + def connect(self): + self.clients = [] + for endpoint in self.urls: + client = etcd3.client(host=endpoint, + ca_cert="/home/nico/vcs/ungleich-dot-cdist/files/etcd/ca.pem", + cert_cert="/home/nico/vcs/ungleich-dot-cdist/files/etcd/nico.pem", + cert_key="/home/nico/vcs/ungleich-dot-cdist/files/etcd/nico-key.pem") + + clients.append(client) + + def get_value(self, key): + pass + + def set_value(self, key, val): + pass + if __name__ == '__main__': endpoints = [ "https://etcd1.ungleich.ch:2379", - "!https://etcd2.ungleich.ch:2379", + "https://etcd2.ungleich.ch:2379", "https://etcd3.ungleich.ch:2379" ] - clients = [] - - for endpoint in endpoints: - client = etcd3.client(host=endpoint, - ca_cert="/home/nico/vcs/ungleich-dot-cdist/files/etcd/ca.pem", - cert_cert="/home/nico/vcs/ungleich-dot-cdist/files/etcd/nico.pem", - cert_key="/home/nico/vcs/ungleich-dot-cdist/files/etcd/nico-key.pem") - - clients.append(client) + db = DB(url=endpoints) diff --git a/uncloud/hack/hackcloud/vm.py b/uncloud/hack/hackcloud/vm.py new file mode 100755 index 0000000..9dd80bf --- /dev/null +++ b/uncloud/hack/hackcloud/vm.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# 2020 Nico Schottelius (nico.schottelius at ungleich.ch) +# +# This file is part of uncloud. +# +# uncloud is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# uncloud is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with uncloud. If not, see . +# +# + +import subprocess +import uuid + +from . import db + +qemu="/usr/bin/qemu-system-x86_64" +accel="kvm" +memory=1024 +cores=2 +uuid=uuid.uuid4() + +#mac=$(./mac-gen.py) +mac="" + +owner="nico" + +bridge="br100" + +if __name__ == '__main__': + p = ["qemu", + "-name", "uncloud-{}".format(uuid), + "-machine", "pc,accel={}".format(accel), + "-m", "{}".format(memory), + "-smp", "{}".format(cores), + "-uuid", "{}".format(uuid), + "-drive", "file=alpine-virt-3.11.2-x86_64.iso,media=cdrom", + "-netdev", "tap,id=netmain,script=./ifup.sh,downscript=./ifdown.sh", + "-device", "virtio-net-pci,netdev=netmain,id=net0,mac={}".format(mac) + ] + print(" ".join(p)) + subprocess.run(p) diff --git a/uncloud/hack/hackcloud/vm.sh b/uncloud/hack/hackcloud/vm.sh index a0e111b..dd9be84 100755 --- a/uncloud/hack/hackcloud/vm.sh +++ b/uncloud/hack/hackcloud/vm.sh @@ -1,5 +1,10 @@ #!/bin/sh +# if [ $# -ne 1 ]; then +# echo "$0: owner" +# exit 1 +# fi + qemu=/usr/bin/qemu-system-x86_64 accel=kvm @@ -9,6 +14,7 @@ memory=1024 cores=2 uuid=$(uuidgen) mac=$(./mac-gen.py) +owner=nico export bridge=br100 diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py new file mode 100644 index 0000000..ce105e8 --- /dev/null +++ b/uncloud/hack/main.py @@ -0,0 +1,10 @@ +import argparse + +arg_parser = argparse.ArgumentParser('hack', add_help=False) +arg_parser.add_argument('--create-vm') + + +def main(arguments): + print(arguments)! + debug = arguments['debug'] + port = arguments['port'] From 22531a7459e1b1b3da7de9b765daddf2d483f5bd Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 14 Jan 2020 11:09:45 +0100 Subject: [PATCH 167/284] Disable cli / otp reading for the moment Imho this should clearly not leak into scripts/uncloud and additionally it is broken at the moment --- scripts/uncloud | 6 +++--- uncloud/hack/main.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/uncloud b/scripts/uncloud index 70cb535..263d99e 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -26,7 +26,7 @@ ETCD_COMPONENTS = ['api', 'scheduler', 'host', 'filescanner', 'imagescanner', 'metadata', 'configure', 'hack'] ALL_COMPONENTS = ETCD_COMPONENTS.copy() -ALL_COMPONENTS.append('cli') +#ALL_COMPONENTS.append('cli') if __name__ == '__main__': @@ -77,8 +77,8 @@ if __name__ == '__main__': # we read from file. But, now we are asking user about where the config file lives. So, # to providing default value is not possible before parsing arguments. So, we are doing # it after.. - settings.settings = settings.Settings(arguments['conf_dir'], seed_value=etcd_arguments) - resolve_otp_credentials(arguments) +# settings.settings = settings.Settings(arguments['conf_dir'], seed_value=etcd_arguments) +# resolve_otp_credentials(arguments) name = arguments.pop('command') mod = importlib.import_module('uncloud.{}.main'.format(name)) diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index ce105e8..2ce19da 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -5,6 +5,6 @@ arg_parser.add_argument('--create-vm') def main(arguments): - print(arguments)! - debug = arguments['debug'] - port = arguments['port'] + print(arguments) + #debug = arguments['debug'] + #port = arguments['port'] From 083ba439183cbedb1baf30a5dfcc0f4da5e65d24 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 14 Jan 2020 11:22:04 +0100 Subject: [PATCH 168/284] Integrate hack + vm create into python code --- uncloud/hack/hackcloud/vm.py | 53 --------------------------------- uncloud/hack/main.py | 9 +++++- uncloud/hack/vm.py | 57 ++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 54 deletions(-) delete mode 100755 uncloud/hack/hackcloud/vm.py create mode 100755 uncloud/hack/vm.py diff --git a/uncloud/hack/hackcloud/vm.py b/uncloud/hack/hackcloud/vm.py deleted file mode 100755 index 9dd80bf..0000000 --- a/uncloud/hack/hackcloud/vm.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# -# 2020 Nico Schottelius (nico.schottelius at ungleich.ch) -# -# This file is part of uncloud. -# -# uncloud is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# uncloud is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with uncloud. If not, see . -# -# - -import subprocess -import uuid - -from . import db - -qemu="/usr/bin/qemu-system-x86_64" -accel="kvm" -memory=1024 -cores=2 -uuid=uuid.uuid4() - -#mac=$(./mac-gen.py) -mac="" - -owner="nico" - -bridge="br100" - -if __name__ == '__main__': - p = ["qemu", - "-name", "uncloud-{}".format(uuid), - "-machine", "pc,accel={}".format(accel), - "-m", "{}".format(memory), - "-smp", "{}".format(cores), - "-uuid", "{}".format(uuid), - "-drive", "file=alpine-virt-3.11.2-x86_64.iso,media=cdrom", - "-netdev", "tap,id=netmain,script=./ifup.sh,downscript=./ifdown.sh", - "-device", "virtio-net-pci,netdev=netmain,id=net0,mac={}".format(mac) - ] - print(" ".join(p)) - subprocess.run(p) diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index 2ce19da..4baed98 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -1,10 +1,17 @@ import argparse +from uncloud.hack.vm import VM + arg_parser = argparse.ArgumentParser('hack', add_help=False) -arg_parser.add_argument('--create-vm') +arg_parser.add_argument('--create-vm', action='store_true') def main(arguments): print(arguments) + if arguments['create_vm']: + print("Creating VM") + vm = VM() + vm.create() + #debug = arguments['debug'] #port = arguments['port'] diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py new file mode 100755 index 0000000..988ea2b --- /dev/null +++ b/uncloud/hack/vm.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# 2020 Nico Schottelius (nico.schottelius at ungleich.ch) +# +# This file is part of uncloud. +# +# uncloud is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# uncloud is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with uncloud. If not, see . +# +# + +import subprocess +import uuid +import os + +class VM(object): + def __init__(self): + self.hackprefix="/home/nico/vcs/uncloud/uncloud/hack/hackcloud" + + self.qemu="/usr/bin/qemu-system-x86_64" + self.accel="kvm" + self.memory=1024 + self.cores=2 + self.uuid=uuid.uuid4() +# self.mac=$(./mac-gen.py) + self.mac="42:00:00:00:00:42" + self.owner="nico" + self.bridge="br100" + self.os_image = os.path.join(self.hackprefix, "alpine-virt-3.11.2-x86_64.iso") + self.ifup = os.path.join(self.hackprefix, "ifup.sh") + self.ifdown = os.path.join(self.hackprefix, "ifdown.sh") + + def create(self): + p = [ "sudo", + "{}".format(self.qemu), + "-name", "uncloud-{}".format(self.uuid), + "-machine", "pc,accel={}".format(self.accel), + "-m", "{}".format(self.memory), + "-smp", "{}".format(self.cores), + "-uuid", "{}".format(self.uuid), + "-drive", "file={},media=cdrom".format(self.os_image), + "-netdev", "tap,id=netmain,script={},downscript={}".format(self.ifup, self.ifdown), + "-device", "virtio-net-pci,netdev=netmain,id=net0,mac={}".format(self.mac) + ] + print(" ".join(p)) + subprocess.run(p) From c0e6d6a0d85dddecccd93f4f20b47b2a3c62f177 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 14 Jan 2020 11:25:06 +0100 Subject: [PATCH 169/284] Begin further integration of code into hack --- uncloud/hack/{hackcloud => }/db.py | 0 uncloud/hack/{hackcloud/mac-gen.py => mac.py} | 0 uncloud/hack/main.py | 1 + 3 files changed, 1 insertion(+) rename uncloud/hack/{hackcloud => }/db.py (100%) rename uncloud/hack/{hackcloud/mac-gen.py => mac.py} (100%) diff --git a/uncloud/hack/hackcloud/db.py b/uncloud/hack/db.py similarity index 100% rename from uncloud/hack/hackcloud/db.py rename to uncloud/hack/db.py diff --git a/uncloud/hack/hackcloud/mac-gen.py b/uncloud/hack/mac.py similarity index 100% rename from uncloud/hack/hackcloud/mac-gen.py rename to uncloud/hack/mac.py diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index 4baed98..2e1e9d5 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -3,6 +3,7 @@ import argparse from uncloud.hack.vm import VM arg_parser = argparse.ArgumentParser('hack', add_help=False) + #description="Commands that are unfinished - use at own risk") arg_parser.add_argument('--create-vm', action='store_true') From 1b36c2f96f945e317e5ef2cec2a5b00d6194ab35 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 14 Jan 2020 14:23:26 +0100 Subject: [PATCH 170/284] Write VM to etcd --- scripts/uncloud | 6 +++--- uncloud/hack/config.py | 39 +++++++++++++++++++++++++++++++++++ uncloud/hack/db.py | 29 ++++++++++++++------------ uncloud/hack/main.py | 8 +++---- uncloud/hack/vm.py | 47 ++++++++++++++++++++++++++++-------------- 5 files changed, 93 insertions(+), 36 deletions(-) create mode 100644 uncloud/hack/config.py diff --git a/scripts/uncloud b/scripts/uncloud index 263d99e..ab5b40d 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -44,7 +44,7 @@ if __name__ == '__main__': default=os.path.expanduser('~/uncloud')) etcd_parser = argparse.ArgumentParser(add_help=False) - etcd_parser.add_argument('--etcd-host', dest='etcd_url') + etcd_parser.add_argument('--etcd-host') etcd_parser.add_argument('--etcd-port') etcd_parser.add_argument('--etcd-ca-cert', help='CA that signed the etcd certificate') etcd_parser.add_argument('--etcd-cert-cert', help='Path to client certificate') @@ -88,7 +88,7 @@ if __name__ == '__main__': main(arguments) except UncloudException as err: logger.error(err) - except ConnectionFailedError: - logger.error('Cannot connect to etcd') +# except ConnectionFailedError as err: +# logger.error('Cannot connect to etcd: {}'.format(err)) except Exception as err: logger.exception(err) diff --git a/uncloud/hack/config.py b/uncloud/hack/config.py new file mode 100644 index 0000000..7e2655d --- /dev/null +++ b/uncloud/hack/config.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# 2020 Nico Schottelius (nico.schottelius at ungleich.ch) +# +# This file is part of uncloud. +# +# uncloud is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# uncloud is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with uncloud. If not, see . +# +# + +class Config(object): + def __init__(self, arguments): + """ read arguments dicts as a base """ + + self.arguments = arguments + + # Split them so *etcd_args can be used and we can + # iterate over etcd_hosts + self.etcd_hosts = [ arguments['etcd_host'] ] + self.etcd_args = { + 'ca_cert': arguments['etcd_ca_cert'], + 'cert_cert': arguments['etcd_cert_cert'], + 'cert_key': arguments['etcd_cert_key'], +# 'user': None, +# 'password': None + } + self.etcd_prefix = '/nicohack/' diff --git a/uncloud/hack/db.py b/uncloud/hack/db.py index 0e6bd0a..be0342a 100644 --- a/uncloud/hack/db.py +++ b/uncloud/hack/db.py @@ -21,28 +21,31 @@ # import etcd3 +import json class DB(object): - def __init__(self, urls): - self.urls = urls - self.prefix = "/nicohack/" + def __init__(self, config): + self.config = config + self.prefix= '/nicohack/' + self.connect() def connect(self): - self.clients = [] - for endpoint in self.urls: - client = etcd3.client(host=endpoint, - ca_cert="/home/nico/vcs/ungleich-dot-cdist/files/etcd/ca.pem", - cert_cert="/home/nico/vcs/ungleich-dot-cdist/files/etcd/nico.pem", - cert_key="/home/nico/vcs/ungleich-dot-cdist/files/etcd/nico-key.pem") - - clients.append(client) + self._db_clients = [] + for endpoint in self.config.etcd_hosts: + client = etcd3.client(host=endpoint, **self.config.etcd_args) + self._db_clients.append(client) def get_value(self, key): pass - def set_value(self, key, val): - pass + def set(self, key, value, store_as_json=False, **kwargs): + if store_as_json: + value = json.dumps(value) + key = "{}/{}".format(self.prefix, key) + + # FIXME: iterate over clients in case of failure ? + return self._db_clients[0].put(key, value, **kwargs) if __name__ == '__main__': endpoints = [ "https://etcd1.ungleich.ch:2379", diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index 2e1e9d5..df618c6 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -1,6 +1,7 @@ import argparse from uncloud.hack.vm import VM +from uncloud.hack.config import Config arg_parser = argparse.ArgumentParser('hack', add_help=False) #description="Commands that are unfinished - use at own risk") @@ -9,10 +10,9 @@ arg_parser.add_argument('--create-vm', action='store_true') def main(arguments): print(arguments) + config = Config(arguments) + if arguments['create_vm']: print("Creating VM") - vm = VM() + vm = VM(config) vm.create() - - #debug = arguments['debug'] - #port = arguments['port'] diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index 988ea2b..e33e473 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -24,34 +24,49 @@ import subprocess import uuid import os -class VM(object): - def __init__(self): - self.hackprefix="/home/nico/vcs/uncloud/uncloud/hack/hackcloud" +from uncloud.hack.db import DB +class VM(object): + def __init__(self, config): + self.config = config + self.db = DB(config) + + self.hackprefix="/home/nico/vcs/uncloud/uncloud/hack/hackcloud" self.qemu="/usr/bin/qemu-system-x86_64" + + self.vm = {} + self.accel="kvm" - self.memory=1024 - self.cores=2 - self.uuid=uuid.uuid4() + # self.mac=$(./mac-gen.py) self.mac="42:00:00:00:00:42" self.owner="nico" self.bridge="br100" - self.os_image = os.path.join(self.hackprefix, "alpine-virt-3.11.2-x86_64.iso") + self.ifup = os.path.join(self.hackprefix, "ifup.sh") self.ifdown = os.path.join(self.hackprefix, "ifdown.sh") - def create(self): - p = [ "sudo", + self.uuid = uuid.uuid4() + self.vm['uuid'] = str(self.uuid) + self.vm['memory']=1024 + self.vm['cores']=2 + self.vm['os_image'] = os.path.join(self.hackprefix, "alpine-virt-3.11.2-x86_64.iso") + + self.vm['commandline' ] = [ "sudo", "{}".format(self.qemu), - "-name", "uncloud-{}".format(self.uuid), + "-name", "uncloud-{}".format(self.vm['uuid']), "-machine", "pc,accel={}".format(self.accel), - "-m", "{}".format(self.memory), - "-smp", "{}".format(self.cores), - "-uuid", "{}".format(self.uuid), - "-drive", "file={},media=cdrom".format(self.os_image), + "-m", "{}".format(self.vm['memory']), + "-smp", "{}".format(self.vm['cores']), + "-uuid", "{}".format(self.vm['uuid']), + "-drive", "file={},media=cdrom".format(self.vm['os_image']), "-netdev", "tap,id=netmain,script={},downscript={}".format(self.ifup, self.ifdown), "-device", "virtio-net-pci,netdev=netmain,id=net0,mac={}".format(self.mac) ] - print(" ".join(p)) - subprocess.run(p) + + def create(self): + self.db.set("vm/{}".format(str(self.vm['uuid'])), + self.vm, store_as_json=True) + + print(" ".join(self.vm['commandline'])) + subprocess.run(self.vm['commandline']) From 8078ffae5a379f338c1e65f4acbb0832a73454f5 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 14 Jan 2020 19:02:15 +0100 Subject: [PATCH 171/284] Add working --last-used-mac {'create_vm': False, 'last_used_mac': True, 'get_new_mac': False, 'debug': False, 'conf_dir': '/home/nico/uncloud', 'etcd_host': 'etcd1.ungleich.ch', 'etcd_port': None, 'etcd_ca_cert': '/home/nico/vcs/ungleich-dot-cdist/files/etcd/ca.pem', 'etcd_cert_cert': '/home/nico/vcs/ungleich-dot-cdist/files/etcd/nico.pem', 'etcd_cert_key': '/home/nico/vcs/ungleich-dot-cdist/files/etcd/nico-key.pem'} 00:20:00:00:00:00 (venv) [19:02] diamond:uncloud% ./bin/uncloud-run-reinstall hack --etcd-host etcd1.ungleich.ch --etcd-ca-cert /home/nico/vcs/ungleich-dot-cdist/files/etcd/ca.pem --etcd-cert-cert /home/nico/vcs/ungleich-dot-cdist/files/etcd/nico.pem --etcd-cert-key /home/nico/vcs/ungleich-dot-cdist/files/etcd/nico-key.pem --last-used-mac --- uncloud/hack/db.py | 63 ++++++++++++++++++++++++++----- uncloud/hack/mac.py | 90 +++++++++++++++----------------------------- uncloud/hack/main.py | 11 ++++++ uncloud/hack/vm.py | 70 +++++++++++++++++----------------- 4 files changed, 132 insertions(+), 102 deletions(-) diff --git a/uncloud/hack/db.py b/uncloud/hack/db.py index be0342a..ac643bd 100644 --- a/uncloud/hack/db.py +++ b/uncloud/hack/db.py @@ -22,30 +22,75 @@ import etcd3 import json +import logging + +from functools import wraps +from uncloud import UncloudException + +log = logging.getLogger(__name__) + + +def readable_errors(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except etcd3.exceptions.ConnectionFailedError as e: + raise UncloudException('Cannot connect to etcd: is etcd running and reachable? {}'.format(e)) + except etcd3.exceptions.ConnectionTimeoutError as e: + raise UncloudException('etcd connection timeout. {}'.format(e)) + + return wrapper + class DB(object): - def __init__(self, config): + def __init__(self, config, prefix="/"): self.config = config - self.prefix= '/nicohack/' + + # Root for everything + self.base_prefix= '/nicohack' + + # Can be set from outside + self.prefix = prefix + self.connect() + @readable_errors def connect(self): self._db_clients = [] for endpoint in self.config.etcd_hosts: client = etcd3.client(host=endpoint, **self.config.etcd_args) self._db_clients.append(client) - def get_value(self, key): - pass + def realkey(self, key): + return "{}{}/{}".format(self.base_prefix, + self.prefix, + key) - def set(self, key, value, store_as_json=False, **kwargs): - if store_as_json: + @readable_errors + def get(self, key, as_json=False, **kwargs): + value, _ = self._db_clients[0].get(self.realkey(key), **kwargs) + + if as_json: + value = json.loads(value) + + return value + + + @readable_errors + def set(self, key, value, as_json=False, **kwargs): + if as_json: value = json.dumps(value) - key = "{}/{}".format(self.prefix, key) - # FIXME: iterate over clients in case of failure ? - return self._db_clients[0].put(key, value, **kwargs) + return self._db_clients[0].put(self.realkey(key), value, **kwargs) + + @readable_errors + def increment(key, **kwargs): + with self._db_clients[0].lock(key) as lock: + value = int(self.get(self.realkey(key), **kwargs)) + self.set(self.realkey(key), str(value + 1), **kwargs) + if __name__ == '__main__': endpoints = [ "https://etcd1.ungleich.ch:2379", diff --git a/uncloud/hack/mac.py b/uncloud/hack/mac.py index e2b4bc5..4ac05f2 100755 --- a/uncloud/hack/mac.py +++ b/uncloud/hack/mac.py @@ -25,93 +25,65 @@ import logging import os.path import os import re +import json + +from uncloud import UncloudException +from uncloud.hack.db import DB log = logging.getLogger(__name__) -class Error(Exception): - pass - -class Mac(object): - def __init__(self): - self.base_dir = "." +class MAC(object): + def __init__(self, config): + self.config = config + self.db = DB(config, prefix="/mac") self.prefix = 0x002000000000 - #self.prefix = "{:012x}".format(self._prefix) - - self.free = self.read_file("mac-free") - self.last = self.read_file("mac-last") - - def read_file(self, filename): - fname = os.path.join(self.base_dir, filename) - ret = [] - - try: - with open(fname, "r") as fd: - ret = fd.readlines() - except Exception as e: - pass - - return ret - - def append_to_file(self, text, filename): - fname = os.path.join(self.base_dir, filename) - with open(fname, "a+") as fd: - fd.write("{}\n".format(text)) @staticmethod def validate_mac(mac): if not re.match(r'([0-9A-F]{2}[-:]){5}[0-9A-F]{2}$', mac, re.I): raise Error("Not a valid mac address: %s" % mac) - def free_append(self, mac): - if mac in self.free: - raise Error("Mac already in free database: %s" % mac) - - self.append_to_file(mac, "mac-free") - self.free = self.read_file("mac-free") + def last_used_index(self): + value = self.db.get("last_used_index") + if not value: + return 0 + return int(value) + def last_used_mac(self): + return self.int_to_mac(self.prefix + self.last_used_index()) @staticmethod def int_to_mac(number): b = number.to_bytes(6, byteorder="big") return ':'.join(format(s, '02x') for s in b) - def getnext(self): + def get_next(self, vmuuid=None, as_int=False): # if self.free: # return self.free.pop() -# if not self.prefix: -# raise Error("Cannot generate address without prefix - use prefix-set") + last_number = self.last_used_index() - if self.last: - last_number = int(self.last[0], 16) - - if last_number == int('0xffffff', 16): - raise Error("Exhausted all possible mac addresses - try to free some") - - next_number = last_number + 1 - else: - next_number = 0 + # FIXME: compare to 48bit minus prefix length + if last_number == int('0xffffff', 16): + raise UncloudException("Exhausted all possible mac addresses - try to free some") + next_number = last_number + 1 next_number_string = "{:012x}".format(next_number) next_mac_number = self.prefix + next_number next_mac = self.int_to_mac(next_mac_number) - with open(os.path.join(self.base_dir, "mac-last"), "w+") as fd: - fd.write("{}\n".format(next_number_string)) + db_entry = {} + db_entry['vm_uuid'] = vmuuid + db_entry['index'] = next_number + db_entry['mac_address'] = next_mac - return next_mac + self.db.set("used/{}".format(next_mac), + db_entry) - @classmethod - def commandline(cls): - pass - - -if __name__ == '__main__': - m = Mac() - m.commandline() - # print(m.free) - #print(m.last) - print(m.getnext()) + if as_int: + return next_mac_number + else: + return next_mac diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index df618c6..ffd0374 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -2,10 +2,13 @@ import argparse from uncloud.hack.vm import VM from uncloud.hack.config import Config +from uncloud.hack.mac import MAC arg_parser = argparse.ArgumentParser('hack', add_help=False) #description="Commands that are unfinished - use at own risk") arg_parser.add_argument('--create-vm', action='store_true') +arg_parser.add_argument('--last-used-mac', action='store_true') +arg_parser.add_argument('--get-new-mac', action='store_true') def main(arguments): @@ -16,3 +19,11 @@ def main(arguments): print("Creating VM") vm = VM(config) vm.create() + + if arguments['last_used_mac']: + m = MAC(config) + print(m.last_used_mac()) + + if arguments['get_new_mac']: + m = MAC(config).get_next() + print(m.last_used()) diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index e33e473..eb75902 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -25,48 +25,50 @@ import uuid import os from uncloud.hack.db import DB +from uncloud.hack.mac import MAC class VM(object): - def __init__(self, config): - self.config = config - self.db = DB(config) + def __init__(self, config): + self.config = config + self.db = DB(config, prefix="/vm") - self.hackprefix="/home/nico/vcs/uncloud/uncloud/hack/hackcloud" - self.qemu="/usr/bin/qemu-system-x86_64" + self.hackprefix="/home/nico/vcs/uncloud/uncloud/hack/hackcloud" + self.qemu="/usr/bin/qemu-system-x86_64" + self.accel="kvm" - self.vm = {} + self.vm = {} - self.accel="kvm" -# self.mac=$(./mac-gen.py) - self.mac="42:00:00:00:00:42" - self.owner="nico" - self.bridge="br100" + self.owner="nico" + self.bridge="br100" - self.ifup = os.path.join(self.hackprefix, "ifup.sh") - self.ifdown = os.path.join(self.hackprefix, "ifdown.sh") + self.ifup = os.path.join(self.hackprefix, "ifup.sh") + self.ifdown = os.path.join(self.hackprefix, "ifdown.sh") - self.uuid = uuid.uuid4() - self.vm['uuid'] = str(self.uuid) - self.vm['memory']=1024 - self.vm['cores']=2 - self.vm['os_image'] = os.path.join(self.hackprefix, "alpine-virt-3.11.2-x86_64.iso") + def create(self): + self.uuid = uuid.uuid4() + self.vm['uuid'] = str(self.uuid) + self.vm['memory'] = 1024 + self.vm['cores'] = 2 + self.vm['os_image'] = os.path.join(self.hackprefix, "alpine-virt-3.11.2-x86_64.iso") - self.vm['commandline' ] = [ "sudo", - "{}".format(self.qemu), - "-name", "uncloud-{}".format(self.vm['uuid']), - "-machine", "pc,accel={}".format(self.accel), - "-m", "{}".format(self.vm['memory']), - "-smp", "{}".format(self.vm['cores']), - "-uuid", "{}".format(self.vm['uuid']), - "-drive", "file={},media=cdrom".format(self.vm['os_image']), - "-netdev", "tap,id=netmain,script={},downscript={}".format(self.ifup, self.ifdown), - "-device", "virtio-net-pci,netdev=netmain,id=net0,mac={}".format(self.mac) - ] + self.mac=MAC().next() - def create(self): - self.db.set("vm/{}".format(str(self.vm['uuid'])), - self.vm, store_as_json=True) + self.vm['commandline' ] = [ "sudo", + "{}".format(self.qemu), + "-name", "uncloud-{}".format(self.vm['uuid']), + "-machine", "pc,accel={}".format(self.accel), + "-m", "{}".format(self.vm['memory']), + "-smp", "{}".format(self.vm['cores']), + "-uuid", "{}".format(self.vm['uuid']), + "-drive", "file={},media=cdrom".format(self.vm['os_image']), + "-netdev", "tap,id=netmain,script={},downscript={}".format(self.ifup, self.ifdown), + "-device", "virtio-net-pci,netdev=netmain,id=net0,mac={}".format(self.mac) + ] - print(" ".join(self.vm['commandline'])) - subprocess.run(self.vm['commandline']) + self.db.set(str(self.vm['uuid']), + self.vm, + as_json=True) + + print(" ".join(self.vm['commandline'])) + subprocess.run(self.vm['commandline']) From 12e8ccd01c62b8dab8f20cb8ff624c5d1d8aac1c Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 14 Jan 2020 19:10:59 +0100 Subject: [PATCH 172/284] Cleanups for mac handling --- uncloud/hack/mac.py | 5 +---- uncloud/hack/main.py | 3 +-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/uncloud/hack/mac.py b/uncloud/hack/mac.py index 4ac05f2..a408103 100755 --- a/uncloud/hack/mac.py +++ b/uncloud/hack/mac.py @@ -60,9 +60,6 @@ class MAC(object): return ':'.join(format(s, '02x') for s in b) def get_next(self, vmuuid=None, as_int=False): -# if self.free: -# return self.free.pop() - last_number = self.last_used_index() # FIXME: compare to 48bit minus prefix length @@ -81,7 +78,7 @@ class MAC(object): db_entry['mac_address'] = next_mac self.db.set("used/{}".format(next_mac), - db_entry) + db_entry, as_json=True) if as_int: return next_mac_number diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index ffd0374..2980516 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -25,5 +25,4 @@ def main(arguments): print(m.last_used_mac()) if arguments['get_new_mac']: - m = MAC(config).get_next() - print(m.last_used()) + print(MAC(config).get_next()) From b877ab13b34b058540fc10fabde67501be1b79f8 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 15 Jan 2020 10:02:37 +0100 Subject: [PATCH 173/284] add hack code --- uncloud/hack/db.py | 21 +++++++++++++++++---- uncloud/hack/mac.py | 8 ++++++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/uncloud/hack/db.py b/uncloud/hack/db.py index ac643bd..cb5e490 100644 --- a/uncloud/hack/db.py +++ b/uncloud/hack/db.py @@ -86,10 +86,23 @@ class DB(object): return self._db_clients[0].put(self.realkey(key), value, **kwargs) @readable_errors - def increment(key, **kwargs): - with self._db_clients[0].lock(key) as lock: - value = int(self.get(self.realkey(key), **kwargs)) - self.set(self.realkey(key), str(value + 1), **kwargs) + def increment(self, key, **kwargs): + print(self.realkey(key)) + + + print("prelock") + lock = self._db_clients[0].lock('/nicohack/foo') + print("prelockacq") + lock.acquire() + print("prelockrelease") + lock.release() + + with self._db_clients[0].lock("/nicohack/mac/last_used_index") as lock: + print("in lock") + pass + +# with self._db_clients[0].lock(self.realkey(key)) as lock:# value = int(self.get(self.realkey(key), **kwargs)) +# self.set(self.realkey(key), str(value + 1), **kwargs) if __name__ == '__main__': diff --git a/uncloud/hack/mac.py b/uncloud/hack/mac.py index a408103..e7f41a2 100755 --- a/uncloud/hack/mac.py +++ b/uncloud/hack/mac.py @@ -48,7 +48,9 @@ class MAC(object): def last_used_index(self): value = self.db.get("last_used_index") if not value: - return 0 + self.db.set("last_used_index", "0") + value = self.db.get("last_used_index") + return int(value) def last_used_mac(self): @@ -62,7 +64,7 @@ class MAC(object): def get_next(self, vmuuid=None, as_int=False): last_number = self.last_used_index() - # FIXME: compare to 48bit minus prefix length + # FIXME: compare to 48bit minus prefix length to the power of 2 if last_number == int('0xffffff', 16): raise UncloudException("Exhausted all possible mac addresses - try to free some") @@ -77,6 +79,8 @@ class MAC(object): db_entry['index'] = next_number db_entry['mac_address'] = next_mac + # should be one transaction + self.db.increment("last_used_index") self.db.set("used/{}".format(next_mac), db_entry, as_json=True) From 26d5c916256ccf91c99de92ae6a80353d58b4720 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 15 Jan 2020 10:53:22 +0100 Subject: [PATCH 174/284] Update hacking docs --- uncloud/docs/source/hacking.rst | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/uncloud/docs/source/hacking.rst b/uncloud/docs/source/hacking.rst index 2df42a7..d198126 100644 --- a/uncloud/docs/source/hacking.rst +++ b/uncloud/docs/source/hacking.rst @@ -1,17 +1,25 @@ Hacking ======= -How to hack on the code. +Using uncloud in hacking (aka development) mode. -[ to be done by Balazs: -* make nice -* indent with shell script mode +Get the code +------------ +.. code-block:: sh + :linenos: -] + git clone https://code.ungleich.ch/uncloud/uncloud.git -* git clone the repo -* cd to the repo -* Setup your venv: python -m venv venv -* . ./venv/bin/activate # you need the leading dot for sourcing! -* Run ./bin/ucloud-run-reinstall - it should print you an error - message on how to use ucloud + + +Install python requirements +--------------------------- +You need to have python3 installed. + +.. code-block:: sh + :linenos: + + cd uncloud! + python -m venv venv + . ./venv/bin/activate + ./bin/uncloud-run-reinstall From bd03f95e9925589375d30e60b0dc4b1960dae6ff Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 15 Jan 2020 11:32:23 +0100 Subject: [PATCH 175/284] [docs] move one level higher --- {uncloud/docs => docs}/Makefile | 2 +- {uncloud/docs => docs}/README.md | 0 {uncloud/docs => docs}/__init__.py | 0 {uncloud/docs => docs}/source/__init__.py | 0 {uncloud/docs => docs}/source/admin-guide | 0 {uncloud/docs => docs}/source/conf.py | 0 {uncloud/docs => docs}/source/diagram-code/ucloud | 0 {uncloud/docs => docs}/source/hacking.rst | 0 {uncloud/docs => docs}/source/images/ucloud.svg | 0 {uncloud/docs => docs}/source/index.rst | 0 {uncloud/docs => docs}/source/introduction.rst | 0 {uncloud/docs => docs}/source/misc/todo.rst | 0 {uncloud/docs => docs}/source/setup-install.rst | 0 {uncloud/docs => docs}/source/theory/summary.rst | 0 {uncloud/docs => docs}/source/troubleshooting.rst | 0 {uncloud/docs => docs}/source/user-guide.rst | 0 .../source/user-guide/how-to-create-an-os-image-for-ucloud.rst | 0 17 files changed, 1 insertion(+), 1 deletion(-) rename {uncloud/docs => docs}/Makefile (93%) rename {uncloud/docs => docs}/README.md (100%) rename {uncloud/docs => docs}/__init__.py (100%) rename {uncloud/docs => docs}/source/__init__.py (100%) rename {uncloud/docs => docs}/source/admin-guide (100%) rename {uncloud/docs => docs}/source/conf.py (100%) rename {uncloud/docs => docs}/source/diagram-code/ucloud (100%) rename {uncloud/docs => docs}/source/hacking.rst (100%) rename {uncloud/docs => docs}/source/images/ucloud.svg (100%) rename {uncloud/docs => docs}/source/index.rst (100%) rename {uncloud/docs => docs}/source/introduction.rst (100%) rename {uncloud/docs => docs}/source/misc/todo.rst (100%) rename {uncloud/docs => docs}/source/setup-install.rst (100%) rename {uncloud/docs => docs}/source/theory/summary.rst (100%) rename {uncloud/docs => docs}/source/troubleshooting.rst (100%) rename {uncloud/docs => docs}/source/user-guide.rst (100%) rename {uncloud/docs => docs}/source/user-guide/how-to-create-an-os-image-for-ucloud.rst (100%) diff --git a/uncloud/docs/Makefile b/docs/Makefile similarity index 93% rename from uncloud/docs/Makefile rename to docs/Makefile index 5e7ea85..246b56c 100644 --- a/uncloud/docs/Makefile +++ b/docs/Makefile @@ -7,7 +7,7 @@ SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = source/ BUILDDIR = build/ -DESTINATION=root@staticweb.ungleich.ch:/home/services/www/ungleichstatic/staticcms.ungleich.ch/www/ucloud/ +DESTINATION=root@staticweb.ungleich.ch:/home/services/www/ungleichstatic/staticcms.ungleich.ch/www/uncloud/ .PHONY: all build clean diff --git a/uncloud/docs/README.md b/docs/README.md similarity index 100% rename from uncloud/docs/README.md rename to docs/README.md diff --git a/uncloud/docs/__init__.py b/docs/__init__.py similarity index 100% rename from uncloud/docs/__init__.py rename to docs/__init__.py diff --git a/uncloud/docs/source/__init__.py b/docs/source/__init__.py similarity index 100% rename from uncloud/docs/source/__init__.py rename to docs/source/__init__.py diff --git a/uncloud/docs/source/admin-guide b/docs/source/admin-guide similarity index 100% rename from uncloud/docs/source/admin-guide rename to docs/source/admin-guide diff --git a/uncloud/docs/source/conf.py b/docs/source/conf.py similarity index 100% rename from uncloud/docs/source/conf.py rename to docs/source/conf.py diff --git a/uncloud/docs/source/diagram-code/ucloud b/docs/source/diagram-code/ucloud similarity index 100% rename from uncloud/docs/source/diagram-code/ucloud rename to docs/source/diagram-code/ucloud diff --git a/uncloud/docs/source/hacking.rst b/docs/source/hacking.rst similarity index 100% rename from uncloud/docs/source/hacking.rst rename to docs/source/hacking.rst diff --git a/uncloud/docs/source/images/ucloud.svg b/docs/source/images/ucloud.svg similarity index 100% rename from uncloud/docs/source/images/ucloud.svg rename to docs/source/images/ucloud.svg diff --git a/uncloud/docs/source/index.rst b/docs/source/index.rst similarity index 100% rename from uncloud/docs/source/index.rst rename to docs/source/index.rst diff --git a/uncloud/docs/source/introduction.rst b/docs/source/introduction.rst similarity index 100% rename from uncloud/docs/source/introduction.rst rename to docs/source/introduction.rst diff --git a/uncloud/docs/source/misc/todo.rst b/docs/source/misc/todo.rst similarity index 100% rename from uncloud/docs/source/misc/todo.rst rename to docs/source/misc/todo.rst diff --git a/uncloud/docs/source/setup-install.rst b/docs/source/setup-install.rst similarity index 100% rename from uncloud/docs/source/setup-install.rst rename to docs/source/setup-install.rst diff --git a/uncloud/docs/source/theory/summary.rst b/docs/source/theory/summary.rst similarity index 100% rename from uncloud/docs/source/theory/summary.rst rename to docs/source/theory/summary.rst diff --git a/uncloud/docs/source/troubleshooting.rst b/docs/source/troubleshooting.rst similarity index 100% rename from uncloud/docs/source/troubleshooting.rst rename to docs/source/troubleshooting.rst diff --git a/uncloud/docs/source/user-guide.rst b/docs/source/user-guide.rst similarity index 100% rename from uncloud/docs/source/user-guide.rst rename to docs/source/user-guide.rst diff --git a/uncloud/docs/source/user-guide/how-to-create-an-os-image-for-ucloud.rst b/docs/source/user-guide/how-to-create-an-os-image-for-ucloud.rst similarity index 100% rename from uncloud/docs/source/user-guide/how-to-create-an-os-image-for-ucloud.rst rename to docs/source/user-guide/how-to-create-an-os-image-for-ucloud.rst From 8a451ff4ffd82ab382183eb1017704c8d4ea25d2 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 15 Jan 2020 12:40:37 +0100 Subject: [PATCH 176/284] [hack] phase in networking --- uncloud/hack/net.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 uncloud/hack/net.py diff --git a/uncloud/hack/net.py b/uncloud/hack/net.py new file mode 100644 index 0000000..142eeb7 --- /dev/null +++ b/uncloud/hack/net.py @@ -0,0 +1,21 @@ +import subprocess + +class VXLANBridge(object): + def __init__(self, bridgedev=None, uplinkdev=None): + self.management_vni = 1 + + cmd_create_vxlan = "ip -6 link add {vxlandev} type vxlan id {netid} dstport 4789 group ff05::{netid} dev {uplinkdev} ttl 5" + cmd_up_dev = "ip link set {dev} up" + cmd_create_bridge="ip link add {bridgedev} type bridge" + cmd_add_to_bridge="ip link set {vxlandev} master {bridgedev} up" + cmd_add_addr="ip addr add {ip} dev {bridgedev}" + + def setup_networking(dev=wlan0, v6net): + ip=2a0a:e5c1:111:888::48/64 + vxlandev=vxlan${netid} + bridgedev=br${netid} + + +class DNSRA(object): + def __init__(self): + pass From 1b5a3f6d2ee71e75bdef9540ff204940b72a1f5c Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 15 Jan 2020 13:26:05 +0100 Subject: [PATCH 177/284] Progress with networking --- docs/source/hacking.rst | 11 +++++++++++ uncloud/hack/main.py | 10 ++++++++++ uncloud/hack/net.py | 4 ++++ 3 files changed, 25 insertions(+) diff --git a/docs/source/hacking.rst b/docs/source/hacking.rst index d198126..1c750d6 100644 --- a/docs/source/hacking.rst +++ b/docs/source/hacking.rst @@ -23,3 +23,14 @@ You need to have python3 installed. python -m venv venv . ./venv/bin/activate ./bin/uncloud-run-reinstall + + + +Install os requirements +----------------------- +Install the following software packages: **dnsmasq**. + +If you already have a working IPv6 SLAAC and DNS setup, +this step can be skipped. + +Note that you need at least one /64 IPv6 network to run uncloud. diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index 2980516..d7a4714 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -3,12 +3,18 @@ import argparse from uncloud.hack.vm import VM from uncloud.hack.config import Config from uncloud.hack.mac import MAC +from uncloud import UncloudException arg_parser = argparse.ArgumentParser('hack', add_help=False) #description="Commands that are unfinished - use at own risk") arg_parser.add_argument('--create-vm', action='store_true') arg_parser.add_argument('--last-used-mac', action='store_true') arg_parser.add_argument('--get-new-mac', action='store_true') +arg_parser.add_argument('--management-network', help="IPv6 management network") +arg_parser.add_argument('--run-dns-ra', action='store_true', + help="Provide router advertisements and DNS resolution via dnsmasq") + + def main(arguments): @@ -26,3 +32,7 @@ def main(arguments): if arguments['get_new_mac']: print(MAC(config).get_next()) + + if arguments['run_dns_ra']: + if not arguments['management_network']: + raise UncloudException("Providing DNS/RAs requires a /64 IPv6 network. You can use fd00::/64 for testing (non production!)") diff --git a/uncloud/hack/net.py b/uncloud/hack/net.py index 142eeb7..0a7819b 100644 --- a/uncloud/hack/net.py +++ b/uncloud/hack/net.py @@ -19,3 +19,7 @@ class VXLANBridge(object): class DNSRA(object): def __init__(self): pass + + +class Firewall(object): + pass From b8472607684a7ca9c73f86296144fe83a6d5e4f4 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 19 Jan 2020 09:16:29 +0100 Subject: [PATCH 178/284] ++network --- uncloud/hack/main.py | 13 +++++++++++++ uncloud/hack/net.py | 6 ++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index d7a4714..4ccb74a 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -3,6 +3,8 @@ import argparse from uncloud.hack.vm import VM from uncloud.hack.config import Config from uncloud.hack.mac import MAC +from uncloud.hack.net import VXLANBridge, DNSRA + from uncloud import UncloudException arg_parser = argparse.ArgumentParser('hack', add_help=False) @@ -10,6 +12,8 @@ arg_parser = argparse.ArgumentParser('hack', add_help=False) arg_parser.add_argument('--create-vm', action='store_true') arg_parser.add_argument('--last-used-mac', action='store_true') arg_parser.add_argument('--get-new-mac', action='store_true') + +arg_parser.add_argument('--init-network', help="Initialise networking") arg_parser.add_argument('--management-network', help="IPv6 management network") arg_parser.add_argument('--run-dns-ra', action='store_true', help="Provide router advertisements and DNS resolution via dnsmasq") @@ -33,6 +37,15 @@ def main(arguments): if arguments['get_new_mac']: print(MAC(config).get_next()) + if arguments['init_networking!']: + if not arguments['management_network']: + raise UncloudException("Initialising the network requires an IPv6 network. You can use fd00::/64 for testing (non production!)") + vb = VXLANBridge(arguments['management_network']) + vb.setup() + if arguments['run_dns_ra']: if not arguments['management_network']: raise UncloudException("Providing DNS/RAs requires a /64 IPv6 network. You can use fd00::/64 for testing (non production!)") + + dnsra = DNSRA(arguments['management_network']) + dnsra.setup() diff --git a/uncloud/hack/net.py b/uncloud/hack/net.py index 0a7819b..11649b8 100644 --- a/uncloud/hack/net.py +++ b/uncloud/hack/net.py @@ -1,7 +1,10 @@ import subprocess +class ManagementBridge(VXLANBridge): + pass + class VXLANBridge(object): - def __init__(self, bridgedev=None, uplinkdev=None): + def __init__(self, vni, bridgedev=None, uplinkdev=None): self.management_vni = 1 cmd_create_vxlan = "ip -6 link add {vxlandev} type vxlan id {netid} dstport 4789 group ff05::{netid} dev {uplinkdev} ttl 5" @@ -11,7 +14,6 @@ class VXLANBridge(object): cmd_add_addr="ip addr add {ip} dev {bridgedev}" def setup_networking(dev=wlan0, v6net): - ip=2a0a:e5c1:111:888::48/64 vxlandev=vxlan${netid} bridgedev=br${netid} From 2b8831784a4d22ec8f20216ccb54139e3da98aeb Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 19 Jan 2020 11:30:16 +0100 Subject: [PATCH 179/284] [pep440] improve versioning name for python --- bin/gen-version | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bin/gen-version b/bin/gen-version index a2e2882..06c3e22 100755 --- a/bin/gen-version +++ b/bin/gen-version @@ -1,22 +1,22 @@ #!/bin/sh # -*- coding: utf-8 -*- # -# 2019 Nico Schottelius (nico-ucloud at schottelius.org) +# 2019-2020 Nico Schottelius (nico-uncloud at schottelius.org) # -# This file is part of ucloud. +# This file is part of uncloud. # -# ucloud is free software: you can redistribute it and/or modify +# uncloud is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # -# ucloud is distributed in the hope that it will be useful, +# uncloud is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License -# along with ucloud. If not, see . +# along with uncloud. If not, see . # # @@ -26,4 +26,4 @@ dir=${0%/*} # Ensure version is present - the bundled/shipped version contains a static version, # the git version contains a dynamic version -printf "VERSION = \"%s\"\n" "$(git describe)" > ${dir}/../uncloud/version.py +printf "VERSION = \"%s\"\n" "$(git describe --tags --abbrev=0)" > ${dir}/../uncloud/version.py From 30be79131212cefb844d79afc86ffbb20ac921ab Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 19 Jan 2020 11:30:30 +0100 Subject: [PATCH 180/284] Be less verbose when reinstalling --- bin/uncloud-run-reinstall | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/uncloud-run-reinstall b/bin/uncloud-run-reinstall index 18e95c0..b211613 100755 --- a/bin/uncloud-run-reinstall +++ b/bin/uncloud-run-reinstall @@ -24,6 +24,6 @@ dir=${0%/*} ${dir}/gen-version; -pip uninstall -y uncloud -python setup.py install +pip uninstall -y uncloud >/dev/null +python setup.py install >/dev/null ${dir}/uncloud "$@" From bd9dbb12b798a1bfe0651cfb7bcae22058ae456b Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 19 Jan 2020 11:30:41 +0100 Subject: [PATCH 181/284] Cleanup networking --- uncloud/hack/main.py | 23 +++++++++++++--------- uncloud/hack/net.py | 45 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index 4ccb74a..cb9fd7b 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -13,8 +13,11 @@ arg_parser.add_argument('--create-vm', action='store_true') arg_parser.add_argument('--last-used-mac', action='store_true') arg_parser.add_argument('--get-new-mac', action='store_true') -arg_parser.add_argument('--init-network', help="Initialise networking") -arg_parser.add_argument('--management-network', help="IPv6 management network") +arg_parser.add_argument('--init-network', help="Initialise networking", action='store_true') +arg_parser.add_argument('--create-vxlan', help="Initialise networking", action='store_true') +arg_parser.add_argument('--network', help="/64 IPv6 network") +arg_parser.add_argument('--vxlan-uplink-device', help="The VXLAN underlay device, i.e. eth0") +arg_parser.add_argument('--vni', help="VXLAN ID (decimal)", type=int) arg_parser.add_argument('--run-dns-ra', action='store_true', help="Provide router advertisements and DNS resolution via dnsmasq") @@ -37,15 +40,17 @@ def main(arguments): if arguments['get_new_mac']: print(MAC(config).get_next()) - if arguments['init_networking!']: - if not arguments['management_network']: - raise UncloudException("Initialising the network requires an IPv6 network. You can use fd00::/64 for testing (non production!)") - vb = VXLANBridge(arguments['management_network']) - vb.setup() + #if arguments['init_network']: + if arguments['create_vxlan']: + if not arguments['network'] or not arguments['vni'] or not arguments['vxlan_uplink_device']: + raise UncloudException("Initialising the network requires an IPv6 network and a VNI. You can use fd00::/64 and vni=1 for testing (non production!)") + vb = VXLANBridge(vni=arguments['vni'], + uplinkdev=arguments['vxlan_uplink_device']) + vb._setup_vxlan() if arguments['run_dns_ra']: - if not arguments['management_network']: + if not arguments['network']: raise UncloudException("Providing DNS/RAs requires a /64 IPv6 network. You can use fd00::/64 for testing (non production!)") - dnsra = DNSRA(arguments['management_network']) + dnsra = DNSRA(arguments['network']) dnsra.setup() diff --git a/uncloud/hack/net.py b/uncloud/hack/net.py index 11649b8..170e7b9 100644 --- a/uncloud/hack/net.py +++ b/uncloud/hack/net.py @@ -1,21 +1,48 @@ import subprocess +import ipaddress + +from uncloud import UncloudException -class ManagementBridge(VXLANBridge): - pass class VXLANBridge(object): - def __init__(self, vni, bridgedev=None, uplinkdev=None): - self.management_vni = 1 - - cmd_create_vxlan = "ip -6 link add {vxlandev} type vxlan id {netid} dstport 4789 group ff05::{netid} dev {uplinkdev} ttl 5" + cmd_create_vxlan = "ip -6 link add {vxlandev} type vxlan id {vni_dec} dstport 4789 group {multicast_address} dev {uplinkdev} ttl 5" cmd_up_dev = "ip link set {dev} up" cmd_create_bridge="ip link add {bridgedev} type bridge" cmd_add_to_bridge="ip link set {vxlandev} master {bridgedev} up" cmd_add_addr="ip addr add {ip} dev {bridgedev}" - def setup_networking(dev=wlan0, v6net): - vxlandev=vxlan${netid} - bridgedev=br${netid} + # VXLAN ids are at maximum 24 bit - use a /104 + multicast_network = ipaddress.IPv6Network("ff05::/104") + max_vni = (2**24)-1 + + def __init__(self, + vni, + uplinkdev): + self.config = {} + + if vni > self.max_vni: + raise UncloudException("VNI must be in the range of 0 .. {}".format(self.max_vni)) + + self.config['vni_dec'] = vni + self.config['vni_hex'] = "{:x}".format(vni) + self.config['multicast_address'] = self.multicast_network[vni] + + self.config['uplinkdev'] = uplinkdev + self.config['vxlandev'] = "vx{}".format(self.config['vni_hex']) + self.config['bridgedev'] = "br{}".format(self.config['vni_hex']) + + + def setup_networking(self): + pass + + def _setup_vxlan(self): + # check for device first (?) + cmd = self.cmd_create_vxlan.format(**self.config) + print(cmd) + subprocess.run(cmd.split()) + +class ManagementBridge(VXLANBridge): + pass class DNSRA(object): From 8888f5d9f7aaaf20d10f1657bb2df60df4a6f912 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 19 Jan 2020 12:55:06 +0100 Subject: [PATCH 182/284] add logging --- scripts/uncloud | 29 ++++++++++---------------- uncloud/hack/main.py | 7 ++++++- uncloud/hack/net.py | 49 ++++++++++++++++++++++++++++++++++++-------- 3 files changed, 57 insertions(+), 28 deletions(-) diff --git a/scripts/uncloud b/scripts/uncloud index ab5b40d..d565954 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -11,17 +11,7 @@ from uncloud.common import settings from uncloud import UncloudException from uncloud.common.cli import resolve_otp_credentials - -def exception_hook(exc_type, exc_value, exc_traceback): - logging.getLogger(__name__).error( - 'Uncaught exception', - exc_info=(exc_type, exc_value, exc_traceback) - ) - - -sys.excepthook = exception_hook - -# the components that use etcd +# Components that use etcd ETCD_COMPONENTS = ['api', 'scheduler', 'host', 'filescanner', 'imagescanner', 'metadata', 'configure', 'hack'] @@ -30,10 +20,6 @@ ALL_COMPONENTS = ETCD_COMPONENTS.copy() if __name__ == '__main__': - # Setting up root logger - logger = logging.getLogger() - logger.setLevel(logging.DEBUG) - arg_parser = argparse.ArgumentParser() subparsers = arg_parser.add_subparsers(dest='command') @@ -84,11 +70,18 @@ if __name__ == '__main__': mod = importlib.import_module('uncloud.{}.main'.format(name)) main = getattr(mod, 'main') + if arguments['debug']: + logging.basicConfig(level=logging.DEBUG) + else: + logging.basicConfig(level=logging.INFO) + + log = logging.getLogger() + try: main(arguments) except UncloudException as err: - logger.error(err) + log.error(err) # except ConnectionFailedError as err: -# logger.error('Cannot connect to etcd: {}'.format(err)) +# log.error('Cannot connect to etcd: {}'.format(err)) except Exception as err: - logger.exception(err) + log.exception(err) diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index cb9fd7b..f275e62 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -20,6 +20,7 @@ arg_parser.add_argument('--vxlan-uplink-device', help="The VXLAN underlay device arg_parser.add_argument('--vni', help="VXLAN ID (decimal)", type=int) arg_parser.add_argument('--run-dns-ra', action='store_true', help="Provide router advertisements and DNS resolution via dnsmasq") +arg_parser.add_argument('--use-sudo', help="Use sudo for command requiring root!", action='store_true') @@ -45,8 +46,12 @@ def main(arguments): if not arguments['network'] or not arguments['vni'] or not arguments['vxlan_uplink_device']: raise UncloudException("Initialising the network requires an IPv6 network and a VNI. You can use fd00::/64 and vni=1 for testing (non production!)") vb = VXLANBridge(vni=arguments['vni'], - uplinkdev=arguments['vxlan_uplink_device']) + route=arguments['network'], + uplinkdev=arguments['vxlan_uplink_device'], + use_sudo=arguments['use_sudo']) vb._setup_vxlan() + vb._setup_bridge() + vb._route_network() if arguments['run_dns_ra']: if not arguments['network']: diff --git a/uncloud/hack/net.py b/uncloud/hack/net.py index 170e7b9..e18b36a 100644 --- a/uncloud/hack/net.py +++ b/uncloud/hack/net.py @@ -1,15 +1,20 @@ import subprocess import ipaddress +import logging + from uncloud import UncloudException +log = logging.getLogger(__name__) + class VXLANBridge(object): - cmd_create_vxlan = "ip -6 link add {vxlandev} type vxlan id {vni_dec} dstport 4789 group {multicast_address} dev {uplinkdev} ttl 5" - cmd_up_dev = "ip link set {dev} up" - cmd_create_bridge="ip link add {bridgedev} type bridge" - cmd_add_to_bridge="ip link set {vxlandev} master {bridgedev} up" - cmd_add_addr="ip addr add {ip} dev {bridgedev}" + cmd_create_vxlan = "{sudo}ip -6 link add {vxlandev} type vxlan id {vni_dec} dstport 4789 group {multicast_address} dev {uplinkdev} ttl 5" + cmd_up_dev = "{sudo}ip link set {dev} up" + cmd_create_bridge="{sudo}ip link add {bridgedev} type bridge" + cmd_add_to_bridge="{sudo}ip link set {vxlandev} master {bridgedev} up" + cmd_add_addr="{sudo}ip addr add {ip} dev {bridgedev}" + cmd_add_route_dev="{sudo}ip route add {route} dev {bridgedev}" # VXLAN ids are at maximum 24 bit - use a /104 multicast_network = ipaddress.IPv6Network("ff05::/104") @@ -17,16 +22,28 @@ class VXLANBridge(object): def __init__(self, vni, - uplinkdev): + uplinkdev, + route=None, + use_sudo=False): self.config = {} if vni > self.max_vni: raise UncloudException("VNI must be in the range of 0 .. {}".format(self.max_vni)) + if use_sudo: + self.config['sudo'] = 'sudo ' + self.config['vni_dec'] = vni self.config['vni_hex'] = "{:x}".format(vni) self.config['multicast_address'] = self.multicast_network[vni] + #try: + self.config['route_network'] = ipaddress.IPv6Network(route) + #except Exception as e: + # print("Ahhhhhhhhhhhhhhhhh, die: {}".format(e)) + + self.config['route'] = route + self.config['uplinkdev'] = uplinkdev self.config['vxlandev'] = "vx{}".format(self.config['vni_hex']) self.config['bridgedev'] = "br{}".format(self.config['vni_hex']) @@ -36,9 +53,23 @@ class VXLANBridge(object): pass def _setup_vxlan(self): - # check for device first (?) - cmd = self.cmd_create_vxlan.format(**self.config) - print(cmd) + self._execute_cmd(self.cmd_create_vxlan) + self._execute_cmd(self.cmd_up_dev, dev=self.config['vxlandev']) + + def _setup_bridge(self): + self._execute_cmd(self.cmd_create_bridge) + self._execute_cmd(self.cmd_up_dev, dev=self.config['bridgedev']) + + def _route_network(self): + self._execute_cmd(self.cmd_add_route_dev) + + def _add_vxlan_to_bridge(self): + self._execute_cmd(self.cmd_add_to_bridge) + + def _execute_cmd(self, cmd_string, **kwargs): + cmd = cmd_string.format(**self.config, **kwargs) + log.info("Executing: {}".format(cmd)) + print("Executing: {}".format(cmd)) subprocess.run(cmd.split()) class ManagementBridge(VXLANBridge): From 8e839aeb44ec47e82d446b3545cbe283f35c80ea Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 23 Jan 2020 18:41:59 +0100 Subject: [PATCH 183/284] commit stuff before dominique does --- uncloud/hack/main.py | 5 +++-- uncloud/hack/net.py | 5 ----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index f275e62..1e38c8a 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -1,4 +1,5 @@ import argparse +import logging from uncloud.hack.vm import VM from uncloud.hack.config import Config @@ -22,11 +23,11 @@ arg_parser.add_argument('--run-dns-ra', action='store_true', help="Provide router advertisements and DNS resolution via dnsmasq") arg_parser.add_argument('--use-sudo', help="Use sudo for command requiring root!", action='store_true') - +log = logging.getLogger(__name__) def main(arguments): - print(arguments) + log.debug("args={}".format(arguments)) config = Config(arguments) if arguments['create_vm']: diff --git a/uncloud/hack/net.py b/uncloud/hack/net.py index e18b36a..e695dc8 100644 --- a/uncloud/hack/net.py +++ b/uncloud/hack/net.py @@ -37,11 +37,7 @@ class VXLANBridge(object): self.config['vni_hex'] = "{:x}".format(vni) self.config['multicast_address'] = self.multicast_network[vni] - #try: self.config['route_network'] = ipaddress.IPv6Network(route) - #except Exception as e: - # print("Ahhhhhhhhhhhhhhhhh, die: {}".format(e)) - self.config['route'] = route self.config['uplinkdev'] = uplinkdev @@ -69,7 +65,6 @@ class VXLANBridge(object): def _execute_cmd(self, cmd_string, **kwargs): cmd = cmd_string.format(**self.config, **kwargs) log.info("Executing: {}".format(cmd)) - print("Executing: {}".format(cmd)) subprocess.run(cmd.split()) class ManagementBridge(VXLANBridge): From 0982927c1bfe2a91b8244e148e3d7098b7c44ede Mon Sep 17 00:00:00 2001 From: Dominique Roux Date: Thu, 23 Jan 2020 18:43:41 +0100 Subject: [PATCH 184/284] Added DNSmasq ability for RA --- uncloud/hack/main.py | 10 ++++++---- uncloud/hack/net.py | 36 ++++++++++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index f275e62..281c251 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -54,8 +54,10 @@ def main(arguments): vb._route_network() if arguments['run_dns_ra']: - if not arguments['network']: - raise UncloudException("Providing DNS/RAs requires a /64 IPv6 network. You can use fd00::/64 for testing (non production!)") + if not arguments['network'] or not arguments['vni']: + raise UncloudException("Providing DNS/RAs requires a /64 IPv6 network and a VNI. You can use fd00::/64 and vni=1 for testing (non production!)") - dnsra = DNSRA(arguments['network']) - dnsra.setup() + dnsra = DNSRA(route=arguments['network'], + vni=arguments['vni'], + use_sudo=arguments['use_sudo']) + dnsra._setup_dnsmasq() diff --git a/uncloud/hack/net.py b/uncloud/hack/net.py index e18b36a..b036198 100644 --- a/uncloud/hack/net.py +++ b/uncloud/hack/net.py @@ -77,9 +77,41 @@ class ManagementBridge(VXLANBridge): class DNSRA(object): - def __init__(self): - pass + # VXLAN ids are at maximum 24 bit + max_vni = (2**24)-1 + # Command to start dnsmasq + cmd_start_dnsmasq="{sudo}dnsmasq --interface={bridgedev} --bind-interfaces --dhcp-range={route},ra-only,infinite --enable-ra" + + def __init__(self, + vni, + route=None, + use_sudo=False): + self.config = {} + + if vni > self.max_vni: + raise UncloudException("VNI must be in the range of 0 .. {}".format(self.max_vni)) + + if use_sudo: + self.config['sudo'] = 'sudo ' + + #TODO: remove if not needed + #self.config['vni_dec'] = vni + self.config['vni_hex'] = "{:x}".format(vni) + + # dnsmasq only wants the network without the prefix, therefore, cut it off + self.config['route'] = ipaddress.IPv6Network(route).network_address + self.config['bridgedev'] = "br{}".format(self.config['vni_hex']) + + def _setup_dnsmasq(self): + self._execute_cmd(self.cmd_start_dnsmasq) + + def _execute_cmd(self, cmd_string, **kwargs): + cmd = cmd_string.format(**self.config, **kwargs) + log.info("Executing: {}".format(cmd)) + print("Executing: {}".format(cmd)) + subprocess.run(cmd.split()) + class Firewall(object): pass From c881c7ce4d65044f0b8bc63de981680f2bab9a1e Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 23 Jan 2020 21:15:26 +0100 Subject: [PATCH 185/284] hack mac: be a proper python class --- uncloud/hack/mac.py | 50 +++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/uncloud/hack/mac.py b/uncloud/hack/mac.py index e7f41a2..084df13 100755 --- a/uncloud/hack/mac.py +++ b/uncloud/hack/mac.py @@ -38,7 +38,8 @@ class MAC(object): self.config = config self.db = DB(config, prefix="/mac") - self.prefix = 0x002000000000 + self.prefix = 0x420000000000 + self._number = 0 # Not set by default @staticmethod def validate_mac(mac): @@ -56,35 +57,40 @@ class MAC(object): def last_used_mac(self): return self.int_to_mac(self.prefix + self.last_used_index()) - @staticmethod - def int_to_mac(number): - b = number.to_bytes(6, byteorder="big") + def to_colon_format(self): + b = self._number.to_bytes(6, byteorder="big") return ':'.join(format(s, '02x') for s in b) - def get_next(self, vmuuid=None, as_int=False): + def to_str_format(self): + b = self._number.to_bytes(6, byteorder="big") + return ''.join(format(s, '02x') for s in b) + + def create(self): last_number = self.last_used_index() - # FIXME: compare to 48bit minus prefix length to the power of 2 - if last_number == int('0xffffff', 16): + if last_number == int('0xffffffff', 16): raise UncloudException("Exhausted all possible mac addresses - try to free some") next_number = last_number + 1 - next_number_string = "{:012x}".format(next_number) + self._number = self.prefix + next_number - next_mac_number = self.prefix + next_number - next_mac = self.int_to_mac(next_mac_number) - - db_entry = {} - db_entry['vm_uuid'] = vmuuid - db_entry['index'] = next_number - db_entry['mac_address'] = next_mac + #next_number_string = "{:012x}".format(next_number) + #next_mac = self.int_to_mac(next_mac_number) + # db_entry = {} + # db_entry['vm_uuid'] = vmuuid + # db_entry['index'] = next_number + # db_entry['mac_address'] = next_mac # should be one transaction - self.db.increment("last_used_index") - self.db.set("used/{}".format(next_mac), - db_entry, as_json=True) + # self.db.increment("last_used_index") + # self.db.set("used/{}".format(next_mac), + # db_entry, as_json=True) - if as_int: - return next_mac_number - else: - return next_mac + def __int__(self): + return self._number + + def __repr__(self): + return self.to_str_format() + + def __str__(self): + return self.to_colon_format() From 46a04048b54dc148d8b5538c0674d56038e00017 Mon Sep 17 00:00:00 2001 From: Dominique Roux Date: Thu, 23 Jan 2020 21:17:09 +0100 Subject: [PATCH 186/284] small changes in vm.py to make it more generic --- uncloud/hack/vm.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index eb75902..e8038cc 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -32,27 +32,35 @@ class VM(object): self.config = config self.db = DB(config, prefix="/vm") - self.hackprefix="/home/nico/vcs/uncloud/uncloud/hack/hackcloud" - self.qemu="/usr/bin/qemu-system-x86_64" - self.accel="kvm" + #TODO: Select generic + self.hackprefix="/home/nico/vcs/uncloud/uncloud/hack/hackcloud" #TODO: Should be removed midterm + self.qemu="/usr/bin/qemu-system-x86_64" #TODO: should be in config + self.accel="kvm" #TODO: should be config self.vm = {} - self.owner="nico" - self.bridge="br100" + #TODO: this should be generic + self.vm['owner']="nico" #TODO: Should in config.arguments + #self.config['vni_hex'] = "{:x}".format(self.config.vni) + #self.config['bridgedev'] = "br{}".format(self.config['vni_hex']) + self.vni_hex = "{:x}".format(self.config.arguments['vni']) + self.bridgedev = "br{}".format(self.vni_hex) + + #TODO: Touch later! (when necessary) self.ifup = os.path.join(self.hackprefix, "ifup.sh") self.ifdown = os.path.join(self.hackprefix, "ifdown.sh") def create(self): self.uuid = uuid.uuid4() + #TODO: This all should be generic self.vm['uuid'] = str(self.uuid) self.vm['memory'] = 1024 self.vm['cores'] = 2 self.vm['os_image'] = os.path.join(self.hackprefix, "alpine-virt-3.11.2-x86_64.iso") - self.mac=MAC().next() + self.mac=MAC(self.config).get_next() self.vm['commandline' ] = [ "sudo", "{}".format(self.qemu), @@ -62,7 +70,7 @@ class VM(object): "-smp", "{}".format(self.vm['cores']), "-uuid", "{}".format(self.vm['uuid']), "-drive", "file={},media=cdrom".format(self.vm['os_image']), - "-netdev", "tap,id=netmain,script={},downscript={}".format(self.ifup, self.ifdown), + "-netdev", "tap,id=netmain,script={},downscript={},ifname={}".format(self.ifup, self.ifdown),self.mac, "-device", "virtio-net-pci,netdev=netmain,id=net0,mac={}".format(self.mac) ] From b5409552d8765afadada6c26793162beb8a5eda3 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 23 Jan 2020 21:20:16 +0100 Subject: [PATCH 187/284] prepare vm.py for dominique --- uncloud/hack/vm.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index e8038cc..1a531e0 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -32,7 +32,7 @@ class VM(object): self.config = config self.db = DB(config, prefix="/vm") - #TODO: Select generic + #TODO: Select generic self.hackprefix="/home/nico/vcs/uncloud/uncloud/hack/hackcloud" #TODO: Should be removed midterm self.qemu="/usr/bin/qemu-system-x86_64" #TODO: should be in config self.accel="kvm" #TODO: should be config @@ -60,7 +60,9 @@ class VM(object): self.vm['cores'] = 2 self.vm['os_image'] = os.path.join(self.hackprefix, "alpine-virt-3.11.2-x86_64.iso") - self.mac=MAC(self.config).get_next() + self.mac=MAC(self.config) + self.mac.create() + self.vm['ifname'] = "uc{}".format(self.mac.to_str_format()) self.vm['commandline' ] = [ "sudo", "{}".format(self.qemu), @@ -74,6 +76,8 @@ class VM(object): "-device", "virtio-net-pci,netdev=netmain,id=net0,mac={}".format(self.mac) ] + # TODO: Add ip link command afterwards (rouxdo) + self.db.set(str(self.vm['uuid']), self.vm, as_json=True) From 58daf8191e3c5d48a96b745a906fb4d9fa2a72e0 Mon Sep 17 00:00:00 2001 From: Dominique Roux Date: Fri, 24 Jan 2020 13:56:08 +0100 Subject: [PATCH 188/284] refactored vm.py to create a VM --- uncloud/hack/mac.py | 14 ++++-- uncloud/hack/main.py | 6 ++- uncloud/hack/vm.py | 106 ++++++++++++++++++++++++------------------- 3 files changed, 75 insertions(+), 51 deletions(-) diff --git a/uncloud/hack/mac.py b/uncloud/hack/mac.py index 084df13..66286dd 100755 --- a/uncloud/hack/mac.py +++ b/uncloud/hack/mac.py @@ -36,7 +36,9 @@ log = logging.getLogger(__name__) class MAC(object): def __init__(self, config): self.config = config - self.db = DB(config, prefix="/mac") + self.no_db = self.config.arguments['no_db'] + if not self.no_db: + self.db = DB(config, prefix="/mac") self.prefix = 0x420000000000 self._number = 0 # Not set by default @@ -47,10 +49,14 @@ class MAC(object): raise Error("Not a valid mac address: %s" % mac) def last_used_index(self): - value = self.db.get("last_used_index") - if not value: - self.db.set("last_used_index", "0") + if not self.no_db: value = self.db.get("last_used_index") + if not value: + self.db.set("last_used_index", "0") + value = self.db.get("last_used_index") + + else: + value = "0" return int(value) diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index 2981184..4778ef6 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -22,6 +22,10 @@ arg_parser.add_argument('--vni', help="VXLAN ID (decimal)", type=int) arg_parser.add_argument('--run-dns-ra', action='store_true', help="Provide router advertisements and DNS resolution via dnsmasq") arg_parser.add_argument('--use-sudo', help="Use sudo for command requiring root!", action='store_true') +arg_parser.add_argument('--memory', help="Size of memory (GB)", type=int) +arg_parser.add_argument('--cores', help="Amount of CPU cores", type=int) +arg_parser.add_argument('--no-db', help="Disable connection to etcd. For local testing only!", action='store_true') + log = logging.getLogger(__name__) @@ -33,7 +37,7 @@ def main(arguments): if arguments['create_vm']: print("Creating VM") vm = VM(config) - vm.create() + vm.commandline() if arguments['last_used_mac']: m = MAC(config) diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index 1a531e0..8e20e2e 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -29,58 +29,72 @@ from uncloud.hack.mac import MAC class VM(object): def __init__(self, config): - self.config = config - self.db = DB(config, prefix="/vm") + self.config = config + #TODO: Enable etcd lookup + self.no_db = self.config.arguments['no_db'] + if not self.no_db: + self.db = DB(self.config, prefix="/vm") - #TODO: Select generic - self.hackprefix="/home/nico/vcs/uncloud/uncloud/hack/hackcloud" #TODO: Should be removed midterm - self.qemu="/usr/bin/qemu-system-x86_64" #TODO: should be in config - self.accel="kvm" #TODO: should be config + #TODO: Select generic + #self.hackprefix="/home/nico/vcs/uncloud/uncloud/hack/hackcloud" #TODO: Should be removed midterm + self.hackprefix="/home/rouxdo/Work/ungleich/uncloud/uncloud/hack/hackcloud" #TODO: Dominique testing + self.qemu="/usr/bin/qemu-system-x86_64" #TODO: should be in config + self.accel="kvm" #TODO: should be config - self.vm = {} + self.vm = {} + #TODO: Touch later! (when necessary) + self.ifup = os.path.join(self.hackprefix, "ifup.sh") + self.ifdown = os.path.join(self.hackprefix, "ifdown.sh") - #TODO: this should be generic - self.vm['owner']="nico" #TODO: Should in config.arguments - #self.config['vni_hex'] = "{:x}".format(self.config.vni) - #self.config['bridgedev'] = "br{}".format(self.config['vni_hex']) - self.vni_hex = "{:x}".format(self.config.arguments['vni']) - self.bridgedev = "br{}".format(self.vni_hex) + def commandline(self): + """This method is used to trigger / create a vm from the cli""" + #TODO: read arguments from cli + #TODO: create etcd json object + self.vm['owner']= "nico" + self.vm['memory'] = self.config.arguments['memory'] + self.vm['cores'] = self.config.arguments['cores'] + self.vm['os_image'] = os.path.join(self.hackprefix, "alpine-virt-3.11.2-x86_64.iso") + self.create_template() + # mimics api call = this will already be in etcd + #self.vm['os_image'] = self.db.get("os_image") + self.create() + def create_template(self): + self.uuid = uuid.uuid4() + #TODO: This all should be generic + self.vm['uuid'] = str(self.uuid) + #self.vni_hex = "{:x}".format(self.config.arguments['vni']) + self.bridgedev = "br{}".format("{:x}".format(self.config.arguments['vni'])) + + #TODO: Enable sudo + if self.config.arguments['use_sudo']: + self.sudo = "sudo" + + self.mac=MAC(self.config) + self.mac.create() + self.vm['ifname'] = "uc{}".format(self.mac.to_str_format()) + + #self.vm['commandline'] = [ "{}".format(self.sudo), + self.vm['commandline'] = [ "{}".format(self.sudo), + "{}".format(self.qemu), + "-name", "uncloud-{}".format(self.vm['uuid']), + "-machine", "pc,accel={}".format(self.accel), + "-m", "{}".format(self.vm['memory']), + "-smp", "{}".format(self.vm['cores']), + "-uuid", "{}".format(self.vm['uuid']), + "-drive", "file={},media=cdrom".format(self.vm['os_image']), + "-netdev", "tap,id=netmain,script={},downscript={},ifname={}".format(self.ifup, self.ifdown, self.mac), + "-device", "virtio-net-pci,netdev=netmain,id=net0,mac={}".format(self.mac) + ] - #TODO: Touch later! (when necessary) - self.ifup = os.path.join(self.hackprefix, "ifup.sh") - self.ifdown = os.path.join(self.hackprefix, "ifdown.sh") def create(self): - self.uuid = uuid.uuid4() - #TODO: This all should be generic - self.vm['uuid'] = str(self.uuid) - self.vm['memory'] = 1024 - self.vm['cores'] = 2 - self.vm['os_image'] = os.path.join(self.hackprefix, "alpine-virt-3.11.2-x86_64.iso") + if not self.no_db: + self.db.set(str(self.vm['uuid']), + self.vm, + as_json=True) - self.mac=MAC(self.config) - self.mac.create() - self.vm['ifname'] = "uc{}".format(self.mac.to_str_format()) - - self.vm['commandline' ] = [ "sudo", - "{}".format(self.qemu), - "-name", "uncloud-{}".format(self.vm['uuid']), - "-machine", "pc,accel={}".format(self.accel), - "-m", "{}".format(self.vm['memory']), - "-smp", "{}".format(self.vm['cores']), - "-uuid", "{}".format(self.vm['uuid']), - "-drive", "file={},media=cdrom".format(self.vm['os_image']), - "-netdev", "tap,id=netmain,script={},downscript={},ifname={}".format(self.ifup, self.ifdown),self.mac, - "-device", "virtio-net-pci,netdev=netmain,id=net0,mac={}".format(self.mac) - ] - - # TODO: Add ip link command afterwards (rouxdo) - - self.db.set(str(self.vm['uuid']), - self.vm, - as_json=True) - - print(" ".join(self.vm['commandline'])) - subprocess.run(self.vm['commandline']) + print(" ".join(self.vm['commandline'])) + subprocess.run(self.vm['commandline']) #TODO: run in background + #TODO: Add interface ifname to bridge brXX (via net.py: public function add iface to bridge) From 7e91f60c0acf75c8d7bae75a8e1068cdbf4784cd Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 24 Jan 2020 14:10:08 +0100 Subject: [PATCH 189/284] sudo fix --- uncloud/hack/net.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/uncloud/hack/net.py b/uncloud/hack/net.py index 6e2a6ee..30d0c03 100644 --- a/uncloud/hack/net.py +++ b/uncloud/hack/net.py @@ -32,6 +32,8 @@ class VXLANBridge(object): if use_sudo: self.config['sudo'] = 'sudo ' + else: + self.config['sudo'] = '' self.config['vni_dec'] = vni self.config['vni_hex'] = "{:x}".format(vni) From 93d7a409b12e2cf7ab3c06a312cc6b4901816db0 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 24 Jan 2020 14:10:49 +0100 Subject: [PATCH 190/284] Fix Dominique's sudo bug Totally not related to my previous commit --- uncloud/hack/net.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/uncloud/hack/net.py b/uncloud/hack/net.py index 30d0c03..f28ab7f 100644 --- a/uncloud/hack/net.py +++ b/uncloud/hack/net.py @@ -92,6 +92,8 @@ class DNSRA(object): if use_sudo: self.config['sudo'] = 'sudo ' + else: + self.config['sudo'] = '' #TODO: remove if not needed #self.config['vni_dec'] = vni From b1319d654af20cd14c5f8f9b82a67a5e58d93098 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 24 Jan 2020 14:15:48 +0100 Subject: [PATCH 191/284] Make me and Dominique happy (aka add vxlan to bridge) --- uncloud/hack/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index 4778ef6..fc54da1 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -56,13 +56,14 @@ def main(arguments): use_sudo=arguments['use_sudo']) vb._setup_vxlan() vb._setup_bridge() + vb._add_vxlan_to_bridge() vb._route_network() if arguments['run_dns_ra']: if not arguments['network'] or not arguments['vni']: raise UncloudException("Providing DNS/RAs requires a /64 IPv6 network and a VNI. You can use fd00::/64 and vni=1 for testing (non production!)") - dnsra = DNSRA(route=arguments['network'], + dnsra = DNSRA(route=arguments['network'], vni=arguments['vni'], use_sudo=arguments['use_sudo']) dnsra._setup_dnsmasq() From ae3482cc71350b8fc85a578798b73f500df45bd7 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 24 Jan 2020 14:21:38 +0100 Subject: [PATCH 192/284] Fix and break some VM stuff --- uncloud/hack/vm.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index 8e20e2e..c41fddc 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -36,8 +36,8 @@ class VM(object): self.db = DB(self.config, prefix="/vm") #TODO: Select generic - #self.hackprefix="/home/nico/vcs/uncloud/uncloud/hack/hackcloud" #TODO: Should be removed midterm - self.hackprefix="/home/rouxdo/Work/ungleich/uncloud/uncloud/hack/hackcloud" #TODO: Dominique testing + self.hackprefix="/home/nico/vcs/uncloud/uncloud/hack/hackcloud" #TODO: Should be removed midterm + #self.hackprefix="/home/rouxdo/Work/ungleich/uncloud/uncloud/hack/hackcloud" #TODO: Dominique testing self.qemu="/usr/bin/qemu-system-x86_64" #TODO: should be in config self.accel="kvm" #TODO: should be config @@ -67,14 +67,19 @@ class VM(object): #self.vni_hex = "{:x}".format(self.config.arguments['vni']) self.bridgedev = "br{}".format("{:x}".format(self.config.arguments['vni'])) - #TODO: Enable sudo + #TODO: Enable sudo -- FIXME! if self.config.arguments['use_sudo']: self.sudo = "sudo" + else: + self.sudo = "" + self.mac=MAC(self.config) self.mac.create() self.vm['ifname'] = "uc{}".format(self.mac.to_str_format()) + # FIXME: TODO: turn this into a string and THEN + # .split() it later -- easier for using .format() #self.vm['commandline'] = [ "{}".format(self.sudo), self.vm['commandline'] = [ "{}".format(self.sudo), "{}".format(self.qemu), @@ -84,7 +89,7 @@ class VM(object): "-smp", "{}".format(self.vm['cores']), "-uuid", "{}".format(self.vm['uuid']), "-drive", "file={},media=cdrom".format(self.vm['os_image']), - "-netdev", "tap,id=netmain,script={},downscript={},ifname={}".format(self.ifup, self.ifdown, self.mac), + "-netdev", "tap,id=netmain,script={},downscript={},ifname={}".format(self.ifup, self.ifdown, self.vm['ifname']), "-device", "virtio-net-pci,netdev=netmain,id=net0,mac={}".format(self.mac) ] From 5711bf4770159821a50e5ef0b677bdba860780c8 Mon Sep 17 00:00:00 2001 From: Dominique Roux Date: Fri, 24 Jan 2020 14:34:34 +0100 Subject: [PATCH 193/284] bugfixes in vm --- uncloud/hack/vm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index 8e20e2e..24eb108 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -54,7 +54,7 @@ class VM(object): self.vm['owner']= "nico" self.vm['memory'] = self.config.arguments['memory'] self.vm['cores'] = self.config.arguments['cores'] - self.vm['os_image'] = os.path.join(self.hackprefix, "alpine-virt-3.11.2-x86_64.iso") + self.vm['os_image'] = os.path.join(self.hackprefix, "alpine-virt-3.11.3-x86_64.iso") self.create_template() # mimics api call = this will already be in etcd #self.vm['os_image'] = self.db.get("os_image") @@ -84,7 +84,7 @@ class VM(object): "-smp", "{}".format(self.vm['cores']), "-uuid", "{}".format(self.vm['uuid']), "-drive", "file={},media=cdrom".format(self.vm['os_image']), - "-netdev", "tap,id=netmain,script={},downscript={},ifname={}".format(self.ifup, self.ifdown, self.mac), + "-netdev", "tap,id=netmain,script={},downscript={},ifname={}".format(self.ifup, self.ifdown, self.vm['ifname']), "-device", "virtio-net-pci,netdev=netmain,id=net0,mac={}".format(self.mac) ] From 5d05e91335925def4ec4342874bef352db205cbd Mon Sep 17 00:00:00 2001 From: Dominique Roux Date: Fri, 24 Jan 2020 17:12:50 +0100 Subject: [PATCH 194/284] added hackerprefix argument, changed the commandline structure of vm to work better with sudo --- uncloud/hack/main.py | 1 + uncloud/hack/vm.py | 40 +++++++++++++++++++++++++--------------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index fc54da1..b6d8fad 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -25,6 +25,7 @@ arg_parser.add_argument('--use-sudo', help="Use sudo for command requiring root! arg_parser.add_argument('--memory', help="Size of memory (GB)", type=int) arg_parser.add_argument('--cores', help="Amount of CPU cores", type=int) arg_parser.add_argument('--no-db', help="Disable connection to etcd. For local testing only!", action='store_true') +arg_parser.add_argument('--hackprefix', help="hackprefix, if you need it you know it (it's where the iso is located and ifup/down.sh") log = logging.getLogger(__name__) diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index bb35348..4caa2fe 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -23,10 +23,14 @@ import subprocess import uuid import os +import logging from uncloud.hack.db import DB from uncloud.hack.mac import MAC + +log = logging.getLogger(__name__) + class VM(object): def __init__(self, config): self.config = config @@ -36,8 +40,9 @@ class VM(object): self.db = DB(self.config, prefix="/vm") #TODO: Select generic - self.hackprefix="/home/nico/vcs/uncloud/uncloud/hack/hackcloud" #TODO: Should be removed midterm + #self.hackprefix="/home/nico/vcs/uncloud/uncloud/hack/hackcloud" #TODO: Should be removed midterm #self.hackprefix="/home/rouxdo/Work/ungleich/uncloud/uncloud/hack/hackcloud" #TODO: Dominique testing + self.hackprefix=self.config.arguments['hackprefix'] self.qemu="/usr/bin/qemu-system-x86_64" #TODO: should be in config self.accel="kvm" #TODO: should be config @@ -69,30 +74,36 @@ class VM(object): #TODO: Enable sudo -- FIXME! if self.config.arguments['use_sudo']: - self.sudo = "sudo" + self.sudo = "sudo " else: self.sudo = "" self.mac=MAC(self.config) self.mac.create() + self.vm['mac'] = self.mac self.vm['ifname'] = "uc{}".format(self.mac.to_str_format()) # FIXME: TODO: turn this into a string and THEN # .split() it later -- easier for using .format() #self.vm['commandline'] = [ "{}".format(self.sudo), - self.vm['commandline'] = [ "{}".format(self.sudo), - "{}".format(self.qemu), - "-name", "uncloud-{}".format(self.vm['uuid']), - "-machine", "pc,accel={}".format(self.accel), - "-m", "{}".format(self.vm['memory']), - "-smp", "{}".format(self.vm['cores']), - "-uuid", "{}".format(self.vm['uuid']), - "-drive", "file={},media=cdrom".format(self.vm['os_image']), - "-netdev", "tap,id=netmain,script={},downscript={},ifname={}".format(self.ifup, self.ifdown, self.vm['ifname']), - "-device", "virtio-net-pci,netdev=netmain,id=net0,mac={}".format(self.mac) - ] + self.vm['commandline'] = "{sudo}{qemu} -name uncloud-{uuid} -machine pc,accel={accel} -m {memory} -smp {cores} -uuid {uuid} -drive file={os_image},media=cdrom -netdev tap,id=netmain,script={ifup},downscript={ifdown},ifname={ifname} -device virtio-net-pci,netdev=netmain,id=net0,mac={mac}" +# self.vm['commandline'] = [ "{}".format(self.sudo), +# "{}".format(self.qemu), +# "-name", "uncloud-{}".format(self.vm['uuid']), +# "-machine", "pc,accel={}".format(self.accel), +# "-m", "{}".format(self.vm['memory']), +# "-smp", "{}".format(self.vm['cores']), +# "-uuid", "{}".format(self.vm['uuid']), +# "-drive", "file={},media=cdrom".format(self.vm['os_image']), +# "-netdev", "tap,id=netmain,script={},downscript={},ifname={}".format(self.ifup, self.ifdown, self.vm['ifname']), +# "-device", "virtio-net-pci,netdev=netmain,id=net0,mac={}".format(self.vm['mac']) +# ] + def _execute_cmd(self, cmd_string, **kwargs): + cmd = cmd_string.format(**self.vm, **kwargs) + log.info("Executing: {}".format(cmd)) + subprocess.run(cmd.split()) def create(self): if not self.no_db: @@ -100,6 +111,5 @@ class VM(object): self.vm, as_json=True) - print(" ".join(self.vm['commandline'])) - subprocess.run(self.vm['commandline']) #TODO: run in background + self._execute_cmd(self.vm['commandline'], sudo=self.sudo, qemu=self.qemu, accel=self.accel, ifup=self.ifup, ifdown=self.ifdown) #TODO: Add interface ifname to bridge brXX (via net.py: public function add iface to bridge) From cbcaf636506e138542e1580098d29057b9558b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Sun, 26 Jan 2020 12:04:37 +0100 Subject: [PATCH 195/284] Update VM images documentation (upstream images, uncloud-init) --- docs/source/{admin-guide => admin-guide.rst} | 39 ++---------- docs/source/index.rst | 5 +- docs/source/vm-images.rst | 66 ++++++++++++++++++++ 3 files changed, 74 insertions(+), 36 deletions(-) rename docs/source/{admin-guide => admin-guide.rst} (72%) create mode 100644 docs/source/vm-images.rst diff --git a/docs/source/admin-guide b/docs/source/admin-guide.rst similarity index 72% rename from docs/source/admin-guide rename to docs/source/admin-guide.rst index ec6597d..b62808d 100644 --- a/docs/source/admin-guide +++ b/docs/source/admin-guide.rst @@ -56,40 +56,13 @@ To start host we created earlier, execute the following command ucloud host ungleich.ch -Create OS Image ---------------- +File & image scanners +-------------------------- -Create ucloud-init ready OS image (Optional) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -This step is optional if you just want to test ucloud. However, sooner or later -you want to create OS images with ucloud-init to properly -contexualize VMs. - -1. Start a VM with OS image on which you want to install ucloud-init -2. Execute the following command on the started VM - - .. code-block:: sh - - apk add git - git clone https://code.ungleich.ch/ucloud/ucloud-init.git - cd ucloud-init - sh ./install.sh -3. Congratulations. Your image is now ucloud-init ready. - - -Upload Sample OS Image -~~~~~~~~~~~~~~~~~~~~~~ -Execute the following to get the sample OS image file. - -.. code-block:: sh - - mkdir /var/www/admin - (cd /var/www/admin && wget https://cloud.ungleich.ch/s/qTb5dFYW5ii8KsD/download) - -Run File Scanner and Image Scanner -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Currently, our uploaded file *alpine-untouched.qcow2* is not tracked by ucloud. We can only make -images from tracked files. So, we need to track the file by running File Scanner +Let's assume we have uploaded an *alpine-uploaded.qcow2* disk images to our +uncloud server. Currently, our *alpine-untouched.qcow2* is not tracked by +ucloud. We can only make images from tracked files. So, we need to track the +file by running File Scanner .. code-block:: sh diff --git a/docs/source/index.rst b/docs/source/index.rst index b31cff3..fad1f88 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -11,14 +11,13 @@ Welcome to ucloud's documentation! :caption: Contents: introduction - user-guide setup-install + vm-images + user-guide admin-guide - user-guide/how-to-create-an-os-image-for-ucloud troubleshooting hacking - Indices and tables ================== diff --git a/docs/source/vm-images.rst b/docs/source/vm-images.rst new file mode 100644 index 0000000..4b2758a --- /dev/null +++ b/docs/source/vm-images.rst @@ -0,0 +1,66 @@ +VM images +================================== + +Overview +--------- + +ucloud tries to be least invasise towards VMs and only require +strictly necessary changes for running in a virtualised +environment. This includes configurations for: + +* Configuring the network +* Managing access via ssh keys +* Resizing the attached disk(s) + +Upstream images +--------------- + +The 'official' uncloud images are defined in the `uncloud/images +`_ repository. + +How to make you own Uncloud images +---------------------------------- + +.. note:: + It is fairly easy to create your own images for uncloud, as the common + operations (which are detailed below) can be automatically handled by the + `uncloud/uncloud-init `_ tool. + +Network configuration +~~~~~~~~~~~~~~~~~~~~~ +All VMs in ucloud are required to support IPv6. The primary network +configuration is always done using SLAAC. A VM thus needs only to be +configured to + +* accept router advertisements on all network interfaces +* use the router advertisements to configure the network interfaces +* accept the DNS entries from the router advertisements + + +Configuring SSH keys +~~~~~~~~~~~~~~~~~~~~ + +To be able to access the VM, ucloud support provisioning SSH keys. + +To accept ssh keys in your VM, request the URL +*http://metadata/ssh_keys*. Add the content to the appropriate user's +**authorized_keys** file. Below you find sample code to accomplish +this task: + +.. code-block:: sh + + tmp=$(mktemp) + curl -s http://metadata/ssk_keys > "$tmp" + touch ~/.ssh/authorized_keys # ensure it exists + cat ~/.ssh/authorized_keys >> "$tmp" + sort "$tmp" | uniq > ~/.ssh/authorized_keys + + +Disk resize +~~~~~~~~~~~ +In virtualised environments, the disk sizes might grow. The operating +system should detect disks that are bigger than the existing partition +table and resize accordingly. This task is os specific. + +ucloud does not support shrinking disks due to the complexity and +intra OS dependencies. From 2b71c1807de324be6a9bb707c561621548e3a48e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 28 Jan 2020 09:25:25 +0100 Subject: [PATCH 196/284] Wire uncloud-hack vm module to VMM --- uncloud/hack/main.py | 20 +++++-- uncloud/hack/vm.py | 130 +++++++++++++++++++++---------------------- 2 files changed, 80 insertions(+), 70 deletions(-) diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index b6d8fad..351f582 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -10,7 +10,6 @@ from uncloud import UncloudException arg_parser = argparse.ArgumentParser('hack', add_help=False) #description="Commands that are unfinished - use at own risk") -arg_parser.add_argument('--create-vm', action='store_true') arg_parser.add_argument('--last-used-mac', action='store_true') arg_parser.add_argument('--get-new-mac', action='store_true') @@ -22,8 +21,15 @@ arg_parser.add_argument('--vni', help="VXLAN ID (decimal)", type=int) arg_parser.add_argument('--run-dns-ra', action='store_true', help="Provide router advertisements and DNS resolution via dnsmasq") arg_parser.add_argument('--use-sudo', help="Use sudo for command requiring root!", action='store_true') + +arg_parser.add_argument('--create-vm', action='store_true') +arg_parser.add_argument('--destroy-vm', action='store_true') +arg_parser.add_argument('--get-vm-status', action='store_true') arg_parser.add_argument('--memory', help="Size of memory (GB)", type=int) arg_parser.add_argument('--cores', help="Amount of CPU cores", type=int) +arg_parser.add_argument('--image', help="Path (under hackprefix) to OS image") +arg_parser.add_argument('--uuid', help="VM UUID") + arg_parser.add_argument('--no-db', help="Disable connection to etcd. For local testing only!", action='store_true') arg_parser.add_argument('--hackprefix', help="hackprefix, if you need it you know it (it's where the iso is located and ifup/down.sh") @@ -32,13 +38,19 @@ log = logging.getLogger(__name__) def main(arguments): - log.debug("args={}".format(arguments)) config = Config(arguments) if arguments['create_vm']: - print("Creating VM") vm = VM(config) - vm.commandline() + vm.create() + + if arguments['destroy_vm']: + vm = VM(config) + vm.stop() + + if arguments['get_vm_status']: + vm = VM(config) + vm.status() if arguments['last_used_mac']: m = MAC(config) diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index 4caa2fe..ce96fbf 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -27,89 +27,87 @@ import logging from uncloud.hack.db import DB from uncloud.hack.mac import MAC - +from uncloud.vmm import VMM log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) class VM(object): def __init__(self, config): self.config = config + #TODO: Enable etcd lookup self.no_db = self.config.arguments['no_db'] if not self.no_db: self.db = DB(self.config, prefix="/vm") - #TODO: Select generic - #self.hackprefix="/home/nico/vcs/uncloud/uncloud/hack/hackcloud" #TODO: Should be removed midterm - #self.hackprefix="/home/rouxdo/Work/ungleich/uncloud/uncloud/hack/hackcloud" #TODO: Dominique testing - self.hackprefix=self.config.arguments['hackprefix'] - self.qemu="/usr/bin/qemu-system-x86_64" #TODO: should be in config - self.accel="kvm" #TODO: should be config + # General CLI arguments. + self.hackprefix = self.config.arguments['hackprefix'] + self.uuid = self.config.arguments['uuid'] + self.memory = self.config.arguments['memory'] or '1024M' + self.cores = self.config.arguments['cores'] or 1 + if self.config.arguments['image']: + self.image = os.path.join(self.hackprefix, self.config.arguments['image']) + else: + self.image = None - self.vm = {} + # External components. + self.vmm = VMM(vmm_backend=self.hackprefix) + self.mac = MAC(self.config) - #TODO: Touch later! (when necessary) + # Harcoded & generated values. + self.owner = 'uncoud' + self.image_format='qcow2' + self.accel = 'kvm' + self.threads = 1 self.ifup = os.path.join(self.hackprefix, "ifup.sh") self.ifdown = os.path.join(self.hackprefix, "ifdown.sh") + self.ifname = "uc{}".format(self.mac.to_str_format()) - def commandline(self): - """This method is used to trigger / create a vm from the cli""" - #TODO: read arguments from cli - #TODO: create etcd json object - self.vm['owner']= "nico" - self.vm['memory'] = self.config.arguments['memory'] - self.vm['cores'] = self.config.arguments['cores'] - self.vm['os_image'] = os.path.join(self.hackprefix, "alpine-virt-3.11.3-x86_64.iso") - self.create_template() - # mimics api call = this will already be in etcd - #self.vm['os_image'] = self.db.get("os_image") - self.create() + def get_qemu_args(self): + command = ( + "-name {owner}-{name}" + " -machine pc,accel={accel}" + " -drive file={image},format={image_format},if=virtio" + " -device virtio-rng-pci" + " -m {memory} -smp cores={cores},threads={threads}" + " -netdev tap,id=netmain,script={ifup},downscript={ifdown},ifname={ifname}" + " -device virtio-net-pci,netdev=netmain,id=net0,mac={mac}" + ).format( + owner=self.owner, name=self.uuid, + accel=self.accel, + image=self.image, image_format=self.image_format, + memory=self.memory, cores=self.cores, threads=self.threads, + ifup=self.ifup, ifdown=self.ifdown, ifname=self.ifname, + mac=self.mac + ) - def create_template(self): - self.uuid = uuid.uuid4() - #TODO: This all should be generic - self.vm['uuid'] = str(self.uuid) - #self.vni_hex = "{:x}".format(self.config.arguments['vni']) - self.bridgedev = "br{}".format("{:x}".format(self.config.arguments['vni'])) - - #TODO: Enable sudo -- FIXME! - if self.config.arguments['use_sudo']: - self.sudo = "sudo " - else: - self.sudo = "" - - - self.mac=MAC(self.config) - self.mac.create() - self.vm['mac'] = self.mac - self.vm['ifname'] = "uc{}".format(self.mac.to_str_format()) - - # FIXME: TODO: turn this into a string and THEN - # .split() it later -- easier for using .format() - #self.vm['commandline'] = [ "{}".format(self.sudo), - self.vm['commandline'] = "{sudo}{qemu} -name uncloud-{uuid} -machine pc,accel={accel} -m {memory} -smp {cores} -uuid {uuid} -drive file={os_image},media=cdrom -netdev tap,id=netmain,script={ifup},downscript={ifdown},ifname={ifname} -device virtio-net-pci,netdev=netmain,id=net0,mac={mac}" -# self.vm['commandline'] = [ "{}".format(self.sudo), -# "{}".format(self.qemu), -# "-name", "uncloud-{}".format(self.vm['uuid']), -# "-machine", "pc,accel={}".format(self.accel), -# "-m", "{}".format(self.vm['memory']), -# "-smp", "{}".format(self.vm['cores']), -# "-uuid", "{}".format(self.vm['uuid']), -# "-drive", "file={},media=cdrom".format(self.vm['os_image']), -# "-netdev", "tap,id=netmain,script={},downscript={},ifname={}".format(self.ifup, self.ifdown, self.vm['ifname']), -# "-device", "virtio-net-pci,netdev=netmain,id=net0,mac={}".format(self.vm['mac']) -# ] - - def _execute_cmd(self, cmd_string, **kwargs): - cmd = cmd_string.format(**self.vm, **kwargs) - log.info("Executing: {}".format(cmd)) - subprocess.run(cmd.split()) + return command.split(" ") def create(self): - if not self.no_db: - self.db.set(str(self.vm['uuid']), - self.vm, - as_json=True) + # New VM: new UUID, new MAC. + self.uuid = str(uuid.uuid4()) + self.mac.create() + + qemu_args = self.get_qemu_args() + log.debug("QEMU args passed to VMM: {}".format(qemu_args)) + self.vmm.start( + uuid=self.uuid, + migration=False, + *qemu_args + ) + + def stop(self): + if not self.uuid: + print("Please specific an UUID with the --uuid flag.") + exit(1) + + self.vmm.stop(self.uuid) + + def status(self): + if not self.uuid: + print("Please specific an UUID with the --uuid flag.") + exit(1) + + print(self.vmm.get_status(self.uuid)) - self._execute_cmd(self.vm['commandline'], sudo=self.sudo, qemu=self.qemu, accel=self.accel, ifup=self.ifup, ifdown=self.ifdown) - #TODO: Add interface ifname to bridge brXX (via net.py: public function add iface to bridge) From 4c6a126d8b0a59a454ec69cbbc867786f0b7b04c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 28 Jan 2020 11:02:18 +0100 Subject: [PATCH 197/284] Hack/VM: wire get_vnc and list_vms --- uncloud/hack/main.py | 10 ++++++++++ uncloud/hack/vm.py | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index 351f582..9607ec2 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -25,6 +25,8 @@ arg_parser.add_argument('--use-sudo', help="Use sudo for command requiring root! arg_parser.add_argument('--create-vm', action='store_true') arg_parser.add_argument('--destroy-vm', action='store_true') arg_parser.add_argument('--get-vm-status', action='store_true') +arg_parser.add_argument('--get-vm-vnc', action='store_true') +arg_parser.add_argument('--list-vms', action='store_true') arg_parser.add_argument('--memory', help="Size of memory (GB)", type=int) arg_parser.add_argument('--cores', help="Amount of CPU cores", type=int) arg_parser.add_argument('--image', help="Path (under hackprefix) to OS image") @@ -52,6 +54,14 @@ def main(arguments): vm = VM(config) vm.status() + if arguments['get_vm_vnc']: + vm = VM(config) + vm.vnc_addr() + + if arguments['list_vms']: + vm = VM(config) + vm.list() + if arguments['last_used_mac']: m = MAC(config) print(m.last_used_mac()) diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index ce96fbf..e9b7719 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -111,3 +111,13 @@ class VM(object): print(self.vmm.get_status(self.uuid)) + def vnc_addr(self): + if not self.uuid: + print("Please specific an UUID with the --uuid flag.") + exit(1) + + print(self.vmm.get_vnc(self.uuid)) + + def list(self): + print(self.vmm.discover()) + From a759b8aa39ae96a08904119b15c5306048c34c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 28 Jan 2020 12:24:26 +0100 Subject: [PATCH 198/284] VMM: make use of socket_dir --- uncloud/vmm/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/uncloud/vmm/__init__.py b/uncloud/vmm/__init__.py index 719bdbe..6db61eb 100644 --- a/uncloud/vmm/__init__.py +++ b/uncloud/vmm/__init__.py @@ -125,7 +125,7 @@ class VMM: os.makedirs(self.socket_dir, exist_ok=True) def is_running(self, uuid): - sock_path = os.path.join(self.vmm_backend, uuid) + sock_path = os.path.join(self.socket_dir, uuid) try: sock = socket.socket(socket.AF_UNIX) sock.connect(sock_path) @@ -163,7 +163,7 @@ class VMM: qmp_arg = ( "-qmp", "unix:{},server,nowait".format( - join_path(self.vmm_backend, uuid) + join_path(self.socket_dir, uuid) ), ) vnc_arg = ( @@ -212,7 +212,7 @@ class VMM: def execute_command(self, uuid, command, **kwargs): # execute_command -> sucess?, output try: - with VMQMPHandles(os.path.join(self.vmm_backend, uuid)) as ( + with VMQMPHandles(os.path.join(self.socket_dir, uuid)) as ( sock_handle, file_handle, ): @@ -255,8 +255,8 @@ class VMM: def discover(self): vms = [ uuid - for uuid in os.listdir(self.vmm_backend) - if not isdir(join_path(self.vmm_backend, uuid)) + for uuid in os.listdir(self.socket_dir) + if not isdir(join_path(self.socket_dir, uuid)) ] return vms From 1758629ca1b861c0406e80591ff35073d7d6331f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 28 Jan 2020 12:33:36 +0100 Subject: [PATCH 199/284] Add minimal doc to hack/vm.py --- uncloud/hack/vm.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index e9b7719..f9cd31a 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -17,8 +17,21 @@ # # You should have received a copy of the GNU General Public License # along with uncloud. If not, see . + +# This module is directly called from the hack module, and can be used as follow: # +# Create a new VM with default CPU/Memory. The path of the image file is relative to $hackprefix. +# `uncloud hack --hackprefix /tmp/hackcloud --create-vm --image mysuperimage.qcow2` # +# List running VMs (returns a list of UUIDs). +# `uncloud hack --hackprefix /tmp/hackcloud --list-vms +# +# Get VM status: +# `uncloud hack --hackprefix /tmp/hackcloud --get-vm-status --uuid my-vm-uuid` +# +# Stop a VM: +# `uncloud hack --hackprefix /tmp/hackcloud --destroy-vm --uuid my-vm-uuid` + `` import subprocess import uuid From e2cd44826b9c307f816d170ed93b3a172edcf712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 28 Jan 2020 13:45:20 +0100 Subject: [PATCH 200/284] Fix typo in hack/vm.py --- uncloud/hack/vm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index f9cd31a..ac403d8 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -31,7 +31,7 @@ # # Stop a VM: # `uncloud hack --hackprefix /tmp/hackcloud --destroy-vm --uuid my-vm-uuid` - `` +# `` import subprocess import uuid From 618fecb73fe3bc77f43d567219a88f3c5cb19b40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 28 Jan 2020 14:38:07 +0100 Subject: [PATCH 201/284] Initial implementation (no networking) of uncloud-oneshot --- scripts/uncloud | 1 + uncloud/oneshot/__init__.py | 3 ++ uncloud/oneshot/main.py | 65 ++++++++++++++++++++++++++++ uncloud/oneshot/virtualmachine.py | 70 +++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 uncloud/oneshot/__init__.py create mode 100644 uncloud/oneshot/main.py create mode 100644 uncloud/oneshot/virtualmachine.py diff --git a/scripts/uncloud b/scripts/uncloud index d565954..7d38e42 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -16,6 +16,7 @@ ETCD_COMPONENTS = ['api', 'scheduler', 'host', 'filescanner', 'imagescanner', 'metadata', 'configure', 'hack'] ALL_COMPONENTS = ETCD_COMPONENTS.copy() +ALL_COMPONENTS.append('oneshot') #ALL_COMPONENTS.append('cli') diff --git a/uncloud/oneshot/__init__.py b/uncloud/oneshot/__init__.py new file mode 100644 index 0000000..eea436a --- /dev/null +++ b/uncloud/oneshot/__init__.py @@ -0,0 +1,3 @@ +import logging + +logger = logging.getLogger(__name__) diff --git a/uncloud/oneshot/main.py b/uncloud/oneshot/main.py new file mode 100644 index 0000000..20f22e4 --- /dev/null +++ b/uncloud/oneshot/main.py @@ -0,0 +1,65 @@ +import argparse +import os + +from pathlib import Path +from uncloud.vmm import VMM + +from . import virtualmachine, logger + +arg_parser = argparse.ArgumentParser('oneshot', add_help=False) +arg_parser.add_argument('--workdir', default=Path.home()) +arg_parser.add_argument('--list-vms', action='store_true') +arg_parser.add_argument('--start-vm', action='store_true') +arg_parser.add_argument('--stop-vm', action='store_true') +arg_parser.add_argument('--name') +arg_parser.add_argument('--image') +arg_parser.add_argument('--uuid') +arg_parser.add_argument('--mac') +arg_parser.add_argument('--get_vm_status', action='store_true') +arg_parser.add_argument('--setup-network') + +def setup_network(): + print("Not implemented yet.") + exit(1) + +def require_with(arguments, required, mode): + if not arguments[required]: + print("--{} is required with the {} flag. Exiting.".format(required, mode)) + exit(1) + +def main(arguments): + # Initialize VMM + workdir = arguments['workdir'] + vmm = VMM(vmm_backend=workdir) + + # Initialize workdir directory. + # TODO: copy ifup, ifdown. + + # Build VM configuration. + vm_config = {} + for spec in ['uuid', 'memory', 'cores', 'threads', 'image', 'image_format', 'name']: + if arguments.get(spec): + vm_config[spec] = arguments[spec] + + # Execute requested VM action. + vm = virtualmachine.VM(vmm, vm_config) + if arguments['setup_network']: + setup_network() + elif arguments['start_vm']: + require_with(arguments, 'image', 'start_vm') + vm.start() + logger.info("Created VM {}".format(vm.get_uuid)) + elif arguments['get_vm_status']: + require_with(arguments, 'uuid', 'get_vm_status') + print("VM: {} {} {}".format(vm.get_uuid(), vm.get_name(), vm.get_status())) + elif arguments['stop_vm']: + require_with(arguments, 'uuid', 'stop_vm') + vm.stop() + elif arguments['list_vms']: + discovered = vmm.discover() + print("Found {} VMs.".format(len(discovered))) + for uuid in vmm.discover(): + vmi = virtualmachine.VM(vmm, {'uuid': uuid}) + print("VM: {} {} {}".format(vmi.get_uuid, vmi.get_name, vmi.get_status)) + else: + print('No action requested. Exiting.') diff --git a/uncloud/oneshot/virtualmachine.py b/uncloud/oneshot/virtualmachine.py new file mode 100644 index 0000000..47365d5 --- /dev/null +++ b/uncloud/oneshot/virtualmachine.py @@ -0,0 +1,70 @@ +import uuid +import os + +from uncloud.oneshot import logger + +class VM(object): + def __init__(self, vmm, config): + self.config = config + self.vmm = vmm + + # Extract VM specs/metadata from configuration. + self.name = config.get('name') + self.memory = config.get('memory', 1024) + self.cores = config.get('cores', 1) + self.threads = config.get('threads', 1) + self.image_format = config.get('image_format', 'qcow2') + self.image = config.get('image') + self.uuid = config.get('uuid', uuid.uuid4()) + self.mac = config.get('mac', 'spuik') + + # Harcoded & generated values. + self.image_format='qcow2' + self.accel = 'kvm' + + def get_qemu_args(self): + command = ( + "-uuid {uuid} -name {name}" + " -drive file={image},format={image_format},if=virtio" + " -device virtio-rng-pci" + " -m {memory} -smp cores={cores},threads={threads}" + ).format( + uuid=self.uuid, name=self.name, + image=self.image, image_format=self.image_format, + memory=self.memory, cores=self.cores, threads=self.threads, + ) + + return command.split(" ") + + def start(self): + # Check that VM image is available. + if not os.path.isfile(self.image): + logger.error("Image {} does not exist. Aborting.".format(self.image)) + + # Generate config for and run QEMU. + qemu_args = self.get_qemu_args() + logger.warning("QEMU args for VM {}: {}".format(self.uuid, qemu_args)) + self.vmm.start( + uuid=self.uuid, + migration=False, + *qemu_args + ) + + def stop(self): + self.vmm.stop(self.uuid) + + def get_status(self): + return self.vmm.get_status(self.uuid) + + def get_vnc_addr(self): + return self.vmm.get_vnc(self.uuid) + + def get_uuid(self): + return self.uuid + + def get_name(self): + success, json = self.vmm.execute_command(uuid, 'query-name') + if success: + return json['return']['name'] + + return None From 3e69fb275fb152cc842582e3a173cdbea8e2e155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 28 Jan 2020 17:44:53 +0100 Subject: [PATCH 202/284] Oneshot: cleanup CLI, initial networking support --- uncloud/oneshot/main.py | 140 +++++++++++++++++++++--------- uncloud/oneshot/virtualmachine.py | 27 ++++-- 2 files changed, 119 insertions(+), 48 deletions(-) diff --git a/uncloud/oneshot/main.py b/uncloud/oneshot/main.py index 20f22e4..0e94a81 100644 --- a/uncloud/oneshot/main.py +++ b/uncloud/oneshot/main.py @@ -1,65 +1,123 @@ import argparse import os + from pathlib import Path from uncloud.vmm import VMM +from uncloud.host.virtualmachine import update_radvd_conf, create_vxlan_br_tap from . import virtualmachine, logger +### +# Argument parser loaded by scripts/uncloud. arg_parser = argparse.ArgumentParser('oneshot', add_help=False) -arg_parser.add_argument('--workdir', default=Path.home()) -arg_parser.add_argument('--list-vms', action='store_true') -arg_parser.add_argument('--start-vm', action='store_true') -arg_parser.add_argument('--stop-vm', action='store_true') -arg_parser.add_argument('--name') -arg_parser.add_argument('--image') -arg_parser.add_argument('--uuid') -arg_parser.add_argument('--mac') -arg_parser.add_argument('--get_vm_status', action='store_true') -arg_parser.add_argument('--setup-network') -def setup_network(): - print("Not implemented yet.") - exit(1) +# Actions. +arg_parser.add_argument('--list', action='store_true', + help='list UUID and name of running VMs') +arg_parser.add_argument('--start', nargs=3, + metavar=('IMAGE', 'UPSTREAM_INTERFACE', 'NETWORK'), + help='start a VM using the OS IMAGE (full path), configuring networking on NETWORK IPv6 prefix') +arg_parser.add_argument('--stop', metavar='UUID', + help='stop a VM') +arg_parser.add_argument('--get-status', metavar='UUID', + help='return the status of the VM') +arg_parser.add_argument('--get-vnc', metavar='UUID', + help='return the path of the VNC socket of the VM') +arg_parser.add_argument('--reconfigure-radvd', metavar='NETWORK', + help='regenerate and reload RADVD configuration for NETWORK IPv6 prefix') -def require_with(arguments, required, mode): - if not arguments[required]: - print("--{} is required with the {} flag. Exiting.".format(required, mode)) - exit(1) +# Arguments. +arg_parser.add_argument('--workdir', default=Path.home(), + help='Working directory, defaulting to $HOME') +arg_parser.add_argument('--mac', + help='MAC address of the VM to create (--start)') +arg_parser.add_argument('--memory', type=int, + help='Memory (MB) to allocate (--start)') +arg_parser.add_argument('--cores', type=int, + help='Number of cores to allocate (--start)') +arg_parser.add_argument('--threads', type=int, + help='Number of threads to allocate (--start)') +arg_parser.add_argument('--image-format', choices=['raw', 'qcow2'], + help='Format of OS image (--start)') +arg_parser.add_argument('--accel', choices=['kvm', 'tcg'], default='tcg', + help='QEMU acceleration to use (--start)') +arg_parser.add_argument('--upstream-interface', default='eth0', + help='Name of upstream interface (--start)') + +### +# Helpers. + +# XXX: check if it is possible to use the type returned by ETCD queries. +class UncloudEntryWrapper: + def __init__(self, value): + self.value = value + + def value(self): + return self.value + +def status_line(vm): + return "VM: {} {} {}".format(vm.get_uuid(), vm.get_name(), vm.get_status()) + +### +# Entrypoint. def main(arguments): - # Initialize VMM + # Initialize VMM. workdir = arguments['workdir'] vmm = VMM(vmm_backend=workdir) - # Initialize workdir directory. - # TODO: copy ifup, ifdown. + # Harcoded debug values. + net_id = 0 # Build VM configuration. vm_config = {} - for spec in ['uuid', 'memory', 'cores', 'threads', 'image', 'image_format', 'name']: - if arguments.get(spec): - vm_config[spec] = arguments[spec] + vm_options = [ + 'mac', 'memory', 'cores', 'threads', 'image', 'image_format', + '--upstream_interface', 'upstream_interface', 'network' + ] + for option in vm_options: + if arguments.get(option): + vm_config[option] = arguments[option] + + vm_config['net_id'] = net_id # Execute requested VM action. - vm = virtualmachine.VM(vmm, vm_config) - if arguments['setup_network']: - setup_network() - elif arguments['start_vm']: - require_with(arguments, 'image', 'start_vm') + if arguments['reconfigure_radvd']: + # TODO: check that RADVD is available. + prefix = arguments['reconfigure_radvd'] + network = UncloudEntryWrapper({ + 'id': net_id, + 'ipv6': prefix + }) + + # Make use of uncloud.host.virtualmachine for network configuration. + update_radvd_conf([network]) + elif arguments['start']: + # Extract from --start positional arguments. Quite fragile. + vm_config['image'] = arguments['start'][0] + vm_config['network'] = arguments['start'][1] + vm_config['upstream_interface'] = arguments['start'][2] + + vm_config['tap_interface'] = "uc{}".format(len(vmm.discover())) + vm = virtualmachine.VM(vmm, vm_config) vm.start() - logger.info("Created VM {}".format(vm.get_uuid)) - elif arguments['get_vm_status']: - require_with(arguments, 'uuid', 'get_vm_status') - print("VM: {} {} {}".format(vm.get_uuid(), vm.get_name(), vm.get_status())) - elif arguments['stop_vm']: - require_with(arguments, 'uuid', 'stop_vm') + elif arguments['stop']: + vm = virtualmachine.VM(vmm, {'uuid': arguments['stop']}) + vm = virtualmachine.VM(vmm, vm_config) vm.stop() - elif arguments['list_vms']: - discovered = vmm.discover() - print("Found {} VMs.".format(len(discovered))) - for uuid in vmm.discover(): - vmi = virtualmachine.VM(vmm, {'uuid': uuid}) - print("VM: {} {} {}".format(vmi.get_uuid, vmi.get_name, vmi.get_status)) + elif arguments['get_status']: + vm = virtualmachine.VM(vmm, {'uuid': arguments['get_status']}) + print(status_line(vm)) + elif arguments['get_vnc']: + vm = virtualmachine.VM(vmm, {'uuid': arguments['get_vnc']}) + print(vm.get_vnc_addr()) + elif arguments['list']: + vms = vmm.discover() + print("Found {} VMs.".format(len(vms))) + for uuid in vms: + vm = virtualmachine.VM(vmm, {'uuid': uuid}) + print(status_line(vm)) else: - print('No action requested. Exiting.') + print('Please specify an action: --start, --stop, --list,\ +--get-status, --get-vnc, --reconfigure-radvd') diff --git a/uncloud/oneshot/virtualmachine.py b/uncloud/oneshot/virtualmachine.py index 47365d5..1388d49 100644 --- a/uncloud/oneshot/virtualmachine.py +++ b/uncloud/oneshot/virtualmachine.py @@ -1,6 +1,7 @@ import uuid import os +from uncloud.host.virtualmachine import create_vxlan_br_tap from uncloud.oneshot import logger class VM(object): @@ -9,29 +10,36 @@ class VM(object): self.vmm = vmm # Extract VM specs/metadata from configuration. - self.name = config.get('name') + self.name = config.get('name', 'no-name') self.memory = config.get('memory', 1024) self.cores = config.get('cores', 1) self.threads = config.get('threads', 1) self.image_format = config.get('image_format', 'qcow2') self.image = config.get('image') - self.uuid = config.get('uuid', uuid.uuid4()) - self.mac = config.get('mac', 'spuik') + self.uuid = config.get('uuid', str(uuid.uuid4())) + self.mac = config.get('mac') + + self.net_id = config.get('net_id', 0) + self.upstream_interface = config.get('upstream_interface', 'eth0') + self.tap_interface = config.get('tap_interface', 'uc0') + self.network = config.get('network') # Harcoded & generated values. - self.image_format='qcow2' self.accel = 'kvm' def get_qemu_args(self): command = ( - "-uuid {uuid} -name {name}" + "-uuid {uuid} -name {name} -machine pc,accel={accel}" " -drive file={image},format={image_format},if=virtio" " -device virtio-rng-pci" " -m {memory} -smp cores={cores},threads={threads}" + " -netdev tap,id=vmnet{net_id},ifname={tap},script=no,downscript=no" + " -device virtio-net-pci,netdev=vmnet{net_id},mac={mac}" ).format( - uuid=self.uuid, name=self.name, + uuid=self.uuid, name=self.name, accel=self.accel, image=self.image, image_format=self.image_format, memory=self.memory, cores=self.cores, threads=self.threads, + net_id=self.net_id, tap=self.tap_interface, mac=self.mac ) return command.split(" ") @@ -41,9 +49,14 @@ class VM(object): if not os.path.isfile(self.image): logger.error("Image {} does not exist. Aborting.".format(self.image)) + # Create Bridge, VXLAN and tap interface for VM. + create_vxlan_br_tap( + self.net_id, self.upstream_interface, self.tap_interface, self.network + ) + # Generate config for and run QEMU. qemu_args = self.get_qemu_args() - logger.warning("QEMU args for VM {}: {}".format(self.uuid, qemu_args)) + logger.debug("QEMU args for VM {}: {}".format(self.uuid, qemu_args)) self.vmm.start( uuid=self.uuid, migration=False, From 5969d3b13df312a5e9734d2412ad209b06ad7bef Mon Sep 17 00:00:00 2001 From: Dominique Roux Date: Wed, 29 Jan 2020 17:04:59 +0100 Subject: [PATCH 203/284] accessed the mac class with the correct function --- uncloud/hack/vm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index 4caa2fe..8d7116c 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -82,7 +82,7 @@ class VM(object): self.mac=MAC(self.config) self.mac.create() self.vm['mac'] = self.mac - self.vm['ifname'] = "uc{}".format(self.mac.to_str_format()) + self.vm['ifname'] = "uc{}".format(self.mac.__repr__()) # FIXME: TODO: turn this into a string and THEN # .split() it later -- easier for using .format() From d8a465bca46e7d693fb4bf32745f170b89ffa5b1 Mon Sep 17 00:00:00 2001 From: Dominique Roux Date: Wed, 29 Jan 2020 17:06:54 +0100 Subject: [PATCH 204/284] Changed Exception in MAC class --- uncloud/hack/mac.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/uncloud/hack/mac.py b/uncloud/hack/mac.py index 66286dd..e35cd9f 100755 --- a/uncloud/hack/mac.py +++ b/uncloud/hack/mac.py @@ -46,7 +46,9 @@ class MAC(object): @staticmethod def validate_mac(mac): if not re.match(r'([0-9A-F]{2}[-:]){5}[0-9A-F]{2}$', mac, re.I): - raise Error("Not a valid mac address: %s" % mac) + raise UncloudException("Not a valid mac address: %s" % mac) + else: + return True def last_used_index(self): if not self.no_db: From 1ca2f8670d8ed10e9dfc1fa60bb35ba2e98160c5 Mon Sep 17 00:00:00 2001 From: Dominique Roux Date: Wed, 29 Jan 2020 17:15:34 +0100 Subject: [PATCH 205/284] Wrote first unit tests --- .gitlab-ci.yml | 5 +++++ test/__init__.py | 0 test/test_mac_local.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 .gitlab-ci.yml create mode 100644 test/__init__.py create mode 100644 test/test_mac_local.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..4cb4c86 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,5 @@ +image: python:3 + +pythonTests: + script: + - python -m unittest -v test/test_mac_local.py diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test_mac_local.py b/test/test_mac_local.py new file mode 100644 index 0000000..3a4ac3a --- /dev/null +++ b/test/test_mac_local.py @@ -0,0 +1,37 @@ +import unittest +from unittest.mock import Mock + +from uncloud.hack.mac import MAC +from uncloud import UncloudException + +class TestMacLocal(unittest.TestCase): + def setUp(self): + self.config = Mock() + self.config.arguments = {"no_db":True} + self.mac = MAC(self.config) + self.mac.create() + + def testMacInt(self): + self.assertEqual(self.mac.__int__(), int("0x420000000001",0), "wrong first MAC index") + + def testMacRepr(self): + self.assertEqual(self.mac.__repr__(), '420000000001', "wrong first MAC index") + + def testMacStr(self): + self.assertEqual(self.mac.__str__(), '42:00:00:00:00:01', "wrong first MAC index") + + def testValidationRaise(self): + with self.assertRaises(UncloudException): + self.mac.validate_mac("2") + + def testValidation(self): + self.assertTrue(self.mac.validate_mac("42:00:00:00:00:01"), "Validation of a given MAC not working properly") + + def testNextMAC(self): + self.mac.create() + self.assertEqual(self.mac.__repr__(), '420000000001', "wrong second MAC index") + self.assertEqual(self.mac.__int__(), int("0x420000000001",0), "wrong second MAC index") + self.assertEqual(self.mac.__str__(), '42:00:00:00:00:01', "wrong second MAC index") + +if __name__ == '__main__': + unittest.main() From 7e36b0c067545dae3d7f14e831d7f51bca58545a Mon Sep 17 00:00:00 2001 From: Dominique Roux Date: Wed, 29 Jan 2020 17:25:29 +0100 Subject: [PATCH 206/284] Debugging pipeline --- .gitlab-ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4cb4c86..e7e1ae9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,8 @@ image: python:3 -pythonTests: +before_script: + - python setup.py install + +python_tests: script: - python -m unittest -v test/test_mac_local.py From 1b08a49aeffe5a81fe2ec4f9bc36ca1b0beb1caa Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 29 Jan 2020 18:45:50 +0100 Subject: [PATCH 207/284] Do not background dnsmasq --- uncloud/hack/net.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud/hack/net.py b/uncloud/hack/net.py index f28ab7f..4887e04 100644 --- a/uncloud/hack/net.py +++ b/uncloud/hack/net.py @@ -79,7 +79,7 @@ class DNSRA(object): # Command to start dnsmasq - cmd_start_dnsmasq="{sudo}dnsmasq --interface={bridgedev} --bind-interfaces --dhcp-range={route},ra-only,infinite --enable-ra" + cmd_start_dnsmasq="{sudo}dnsmasq --interface={bridgedev} --bind-interfaces --dhcp-range={route},ra-only,infinite --enable-ra --no-daemon" def __init__(self, vni, From 56565ac7f7b6405758a4cd5cb41a1b11ed550c49 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 29 Jan 2020 19:30:19 +0100 Subject: [PATCH 208/284] Fix AttributeError: 'VM' object has no attribute 'vm' ERROR:uncloud.vmm:Error occurred while starting VM. Detail qemu-system-x86_64: -drive file=/home/nico/vcs/uncloud/uncloud/hack/hackcloud/alpine-virt-3.11.2-x86_64.iso,format=qcow2,if=virtio: Image is not in qcow2 format Traceback (most recent call last): File "/home/nico/vcs/uncloud/uncloud/vmm/__init__.py", line 186, in start sp.check_output(command, stderr=sp.PIPE) File "/usr/lib/python3.8/subprocess.py", line 411, in check_output return run(*popenargs, stdout=PIPE, timeout=timeout, check=True, File "/usr/lib/python3.8/subprocess.py", line 512, in run raise CalledProcessError(retcode, process.args, subprocess.CalledProcessError: Command '['sudo', '-p', 'Enter password to start VM 87230168-1b74-49f7-97c3-c968a26fc65e: ', '/usr/bin/qemu-system-x86_64', '-name', 'uncoud-87230168-1b74-49f7-97c3-c968a26fc65e', '-machine', 'pc,accel=kvm', '-drive', 'file=/home/nico/vcs/uncloud/uncloud/hack/hackcloud/alpine-virt-3.11.2-x86_64.iso,format=qcow2,if=virtio', '-device', 'virtio-rng-pci', '-m', '1024M', '-smp', 'cores=1,threads=1', '-netdev', 'tap,id=netmain,script=/home/nico/vcs/uncloud/uncloud/hack/hackcloud/ifup.sh,downscript=/home/nico/vcs/uncloud/uncloud/hack/hackcloud/ifdown.sh,ifname=uc000000000000', '-device', 'virtio-net-pci,netdev=netmain,id=net0,mac=42:00:00:00:00:01', '-qmp', 'unix:/home/nico/vcs/uncloud/uncloud/hack/hackcloud/sock/87230168-1b74-49f7-97c3-c968a26fc65e,server,nowait', '-vnc', 'unix:/tmp/tmpep71nz1f', '-daemonize']' returned non-zero exit status 1. ERROR:root:'VM' object has no attribute 'vm' Traceback (most recent call last): File "./bin/../scripts/uncloud", line 82, in main(arguments) File "/home/nico/vcs/uncloud/uncloud/hack/main.py", line 47, in main vm.create() File "/home/nico/vcs/uncloud/uncloud/hack/vm.py", line 115, in create self.vm['mac'] = self.mac AttributeError: 'VM' object has no attribute 'vm' (venv) [18:49] diamond:uncloud% ./bin/uncloud-run-reinstall hack --create-vm --hackprefix ~/vcs/uncloud/uncloud/hack/hackcloud/ --image alpine-virt-3.11.2-x86_64.iso --no-db --- uncloud/hack/vm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index e1b2f81..7804d18 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -100,6 +100,7 @@ class VM(object): def create(self): # New VM: new UUID, new MAC. self.uuid = str(uuid.uuid4()) + self.mac=MAC(self.config) self.mac.create() qemu_args = self.get_qemu_args() @@ -110,7 +111,7 @@ class VM(object): *qemu_args ) - self.mac=MAC(self.config) + self.mac.create() self.vm['mac'] = self.mac self.vm['ifname'] = "uc{}".format(self.mac.__repr__()) @@ -159,4 +160,3 @@ class VM(object): def list(self): print(self.vmm.discover()) - From 3171ab8ccb02ab4f7feeb531401cd8d3851aefb9 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 29 Jan 2020 19:55:55 +0100 Subject: [PATCH 209/284] [hack/vm] add self.vm dict --- uncloud/hack/vm.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index 7804d18..d86941b 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -77,6 +77,8 @@ class VM(object): self.ifdown = os.path.join(self.hackprefix, "ifdown.sh") self.ifname = "uc{}".format(self.mac.to_str_format()) + self.vm = {} + def get_qemu_args(self): command = ( "-name {owner}-{name}" From 17d0c61407f99ea13e68af3171711a2af09d2693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 30 Jan 2020 08:47:23 +0100 Subject: [PATCH 210/284] Fix --accel parameter for oneshot --- uncloud/oneshot/main.py | 4 ++-- uncloud/oneshot/virtualmachine.py | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/uncloud/oneshot/main.py b/uncloud/oneshot/main.py index 0e94a81..5b9b61c 100644 --- a/uncloud/oneshot/main.py +++ b/uncloud/oneshot/main.py @@ -40,7 +40,7 @@ arg_parser.add_argument('--threads', type=int, help='Number of threads to allocate (--start)') arg_parser.add_argument('--image-format', choices=['raw', 'qcow2'], help='Format of OS image (--start)') -arg_parser.add_argument('--accel', choices=['kvm', 'tcg'], default='tcg', +arg_parser.add_argument('--accel', choices=['kvm', 'tcg'], default='kvm', help='QEMU acceleration to use (--start)') arg_parser.add_argument('--upstream-interface', default='eth0', help='Name of upstream interface (--start)') @@ -74,7 +74,7 @@ def main(arguments): vm_config = {} vm_options = [ 'mac', 'memory', 'cores', 'threads', 'image', 'image_format', - '--upstream_interface', 'upstream_interface', 'network' + '--upstream_interface', 'upstream_interface', 'network', 'accel' ] for option in vm_options: if arguments.get(option): diff --git a/uncloud/oneshot/virtualmachine.py b/uncloud/oneshot/virtualmachine.py index 1388d49..c8c2909 100644 --- a/uncloud/oneshot/virtualmachine.py +++ b/uncloud/oneshot/virtualmachine.py @@ -18,15 +18,13 @@ class VM(object): self.image = config.get('image') self.uuid = config.get('uuid', str(uuid.uuid4())) self.mac = config.get('mac') + self.accel = config.get('accel', 'kvm') self.net_id = config.get('net_id', 0) self.upstream_interface = config.get('upstream_interface', 'eth0') self.tap_interface = config.get('tap_interface', 'uc0') self.network = config.get('network') - # Harcoded & generated values. - self.accel = 'kvm' - def get_qemu_args(self): command = ( "-uuid {uuid} -name {name} -machine pc,accel={accel}" From 9e2751c41eac082209ffbb376197c185a51d3141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 30 Jan 2020 08:52:24 +0100 Subject: [PATCH 211/284] Remove deplicate vm definition in oneshot --stop --- uncloud/oneshot/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/uncloud/oneshot/main.py b/uncloud/oneshot/main.py index 5b9b61c..0e56571 100644 --- a/uncloud/oneshot/main.py +++ b/uncloud/oneshot/main.py @@ -104,7 +104,6 @@ def main(arguments): vm.start() elif arguments['stop']: vm = virtualmachine.VM(vmm, {'uuid': arguments['stop']}) - vm = virtualmachine.VM(vmm, vm_config) vm.stop() elif arguments['get_status']: vm = virtualmachine.VM(vmm, {'uuid': arguments['get_status']}) From 8797e93bafff6dfad1c97991d54e9a704d43d48c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 30 Jan 2020 08:54:58 +0100 Subject: [PATCH 212/284] Fix --name support in oneshot --- uncloud/oneshot/main.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/uncloud/oneshot/main.py b/uncloud/oneshot/main.py index 0e56571..4e92a5c 100644 --- a/uncloud/oneshot/main.py +++ b/uncloud/oneshot/main.py @@ -16,7 +16,7 @@ arg_parser = argparse.ArgumentParser('oneshot', add_help=False) arg_parser.add_argument('--list', action='store_true', help='list UUID and name of running VMs') arg_parser.add_argument('--start', nargs=3, - metavar=('IMAGE', 'UPSTREAM_INTERFACE', 'NETWORK'), + metavar=('NAME', 'IMAGE', 'UPSTREAM_INTERFACE', 'NETWORK'), help='start a VM using the OS IMAGE (full path), configuring networking on NETWORK IPv6 prefix') arg_parser.add_argument('--stop', metavar='UUID', help='stop a VM') @@ -95,9 +95,10 @@ def main(arguments): update_radvd_conf([network]) elif arguments['start']: # Extract from --start positional arguments. Quite fragile. - vm_config['image'] = arguments['start'][0] - vm_config['network'] = arguments['start'][1] - vm_config['upstream_interface'] = arguments['start'][2] + vm_config['name'] = arguments['start'][0] + vm_config['image'] = arguments['start'][1] + vm_config['network'] = arguments['start'][2] + vm_config['upstream_interface'] = arguments['start'][3] vm_config['tap_interface'] = "uc{}".format(len(vmm.discover())) vm = virtualmachine.VM(vmm, vm_config) From f2337a14eb325e0784290f017859cf9669808433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 30 Jan 2020 08:55:56 +0100 Subject: [PATCH 213/284] Yet another forgotten CLI parameter in oneshot... --- uncloud/oneshot/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud/oneshot/main.py b/uncloud/oneshot/main.py index 4e92a5c..dbb3b32 100644 --- a/uncloud/oneshot/main.py +++ b/uncloud/oneshot/main.py @@ -15,7 +15,7 @@ arg_parser = argparse.ArgumentParser('oneshot', add_help=False) # Actions. arg_parser.add_argument('--list', action='store_true', help='list UUID and name of running VMs') -arg_parser.add_argument('--start', nargs=3, +arg_parser.add_argument('--start', nargs=4, metavar=('NAME', 'IMAGE', 'UPSTREAM_INTERFACE', 'NETWORK'), help='start a VM using the OS IMAGE (full path), configuring networking on NETWORK IPv6 prefix') arg_parser.add_argument('--stop', metavar='UUID', From 0e667b5262b238b74bc4a22f71a635561fde6f49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 30 Jan 2020 09:00:28 +0100 Subject: [PATCH 214/284] Fix UUID variable in oneshot/vm/get_name --- uncloud/oneshot/virtualmachine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud/oneshot/virtualmachine.py b/uncloud/oneshot/virtualmachine.py index c8c2909..5749bee 100644 --- a/uncloud/oneshot/virtualmachine.py +++ b/uncloud/oneshot/virtualmachine.py @@ -74,7 +74,7 @@ class VM(object): return self.uuid def get_name(self): - success, json = self.vmm.execute_command(uuid, 'query-name') + success, json = self.vmm.execute_command(self.uuid, 'query-name') if success: return json['return']['name'] From aaf0114df1c4e09ee5cc57386479903a08774e03 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 6 Feb 2020 15:13:08 +0100 Subject: [PATCH 215/284] add image format option --- uncloud/hack/main.py | 1 + uncloud/hack/vm.py | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index 9607ec2..a0c3ca6 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -30,6 +30,7 @@ arg_parser.add_argument('--list-vms', action='store_true') arg_parser.add_argument('--memory', help="Size of memory (GB)", type=int) arg_parser.add_argument('--cores', help="Amount of CPU cores", type=int) arg_parser.add_argument('--image', help="Path (under hackprefix) to OS image") +arg_parser.add_argument('--image-format', help="Image format: qcow2 or raw", choices=['raw', 'qcow2']) arg_parser.add_argument('--uuid', help="VM UUID") arg_parser.add_argument('--no-db', help="Disable connection to etcd. For local testing only!", action='store_true') diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index d86941b..b38d563 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -59,18 +59,23 @@ class VM(object): self.uuid = self.config.arguments['uuid'] self.memory = self.config.arguments['memory'] or '1024M' self.cores = self.config.arguments['cores'] or 1 + if self.config.arguments['image']: self.image = os.path.join(self.hackprefix, self.config.arguments['image']) else: self.image = None + if self.config.arguments['image_format']: + self.image_format=self.config.arguments['image_format'] + else: + self.image_format='qcow2' + # External components. self.vmm = VMM(vmm_backend=self.hackprefix) self.mac = MAC(self.config) # Harcoded & generated values. self.owner = 'uncoud' - self.image_format='qcow2' self.accel = 'kvm' self.threads = 1 self.ifup = os.path.join(self.hackprefix, "ifup.sh") From 592b745cea6eb76b973218d8fe55934e26a30cf2 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 6 Feb 2020 15:32:48 +0100 Subject: [PATCH 216/284] exit if an exception happened --- scripts/uncloud | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/uncloud b/scripts/uncloud index 7d38e42..9517b01 100755 --- a/scripts/uncloud +++ b/scripts/uncloud @@ -82,6 +82,7 @@ if __name__ == '__main__': main(arguments) except UncloudException as err: log.error(err) + sys.exit(1) # except ConnectionFailedError as err: # log.error('Cannot connect to etcd: {}'.format(err)) except Exception as err: From d9a756b50efe388aeaec63133e97d8dbb4dca004 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 6 Feb 2020 15:33:01 +0100 Subject: [PATCH 217/284] Catch filenotfound errors when launching etcd --- uncloud/hack/db.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/uncloud/hack/db.py b/uncloud/hack/db.py index cb5e490..9086865 100644 --- a/uncloud/hack/db.py +++ b/uncloud/hack/db.py @@ -53,7 +53,10 @@ class DB(object): # Can be set from outside self.prefix = prefix - self.connect() + try: + self.connect() + except FileNotFoundError as e: + raise UncloudException("Is the path to the etcd certs correct? {}".format(e)) @readable_errors def connect(self): From f99d0a0b642b6be41eff1a83ad912a15ebc545c5 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 9 Feb 2020 08:43:56 +0100 Subject: [PATCH 218/284] [requirements] add ldap3 --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 12da6b8..f5e0718 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,8 @@ setup( "pynetbox", "colorama", "etcd3 @ https://github.com/kragniz/python-etcd3/tarball/master#egg=etcd3", - "marshmallow" + "marshmallow", + "ldap3" ], scripts=["scripts/uncloud"], data_files=[ From 55a2de72c881c26b989d9e71bc07bd6188a9d783 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 9 Feb 2020 08:51:35 +0100 Subject: [PATCH 219/284] [hack] begin to add ldap authentication --- uncloud/hack/main.py | 48 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index a0c3ca6..391a5e4 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -1,6 +1,8 @@ import argparse import logging +import ldap3 + from uncloud.hack.vm import VM from uncloud.hack.config import Config from uncloud.hack.mac import MAC @@ -27,22 +29,64 @@ arg_parser.add_argument('--destroy-vm', action='store_true') arg_parser.add_argument('--get-vm-status', action='store_true') arg_parser.add_argument('--get-vm-vnc', action='store_true') arg_parser.add_argument('--list-vms', action='store_true') -arg_parser.add_argument('--memory', help="Size of memory (GB)", type=int) -arg_parser.add_argument('--cores', help="Amount of CPU cores", type=int) +arg_parser.add_argument('--memory', help="Size of memory (GB)", type=int, default=2) +arg_parser.add_argument('--cores', help="Amount of CPU cores", type=int, default=1) arg_parser.add_argument('--image', help="Path (under hackprefix) to OS image") + arg_parser.add_argument('--image-format', help="Image format: qcow2 or raw", choices=['raw', 'qcow2']) arg_parser.add_argument('--uuid', help="VM UUID") arg_parser.add_argument('--no-db', help="Disable connection to etcd. For local testing only!", action='store_true') arg_parser.add_argument('--hackprefix', help="hackprefix, if you need it you know it (it's where the iso is located and ifup/down.sh") +# order based commands => later to be shifted below "order" +arg_parser.add_argument('--order', action='store_true') +arg_parser.add_argument('--product', choices=["dualstack-vm"]) +arg_parser.add_argument('--os-image-name', help="Name of OS image (successor to --image)") +arg_parser.add_argument('--os-image-size', help="Size of OS image in GB", type=int, default=10) + +arg_parser.add_argument('--username') +arg_parser.add_argument('--password') + log = logging.getLogger(__name__) +def authenticate(username, password, totp_token=None): + server = ldap3.Server("ldaps://ldap1.ungleich.ch") + dn = "uid={},ou=customer,dc=ungleich,dc=ch".format(username) + + try: + conn = ldap3.Connection(server, dn, password, auto_bind=True) + except ldap3.core.exceptions.LDAPBindError as e: + raise UncloudException("Credentials not verified by LDAP server: {}".format(e)) + + + +def order(config): + for required_arg in [ 'product', 'username', 'password' ]: + if not config.arguments[required_arg]: + raise UncloudException("Missing required argument: {}".format(required_arg)) + + if config.arguments['product'] == 'dualstack-vm': + for required_arg in [ 'cores', 'memory', 'os_image_name', 'os_image_size' ]: + if not config.arguments[required_arg]: + raise UncloudException("Missing required argument: {}".format(required_arg)) + + print(config.arguments) + authenticate(config.arguments['username'], config.arguments['password']) + + # create DB entry for VM + vm = VM(config) + vm.schedule() + + def main(arguments): config = Config(arguments) + if arguments['order']: + order(config) + if arguments['create_vm']: vm = VM(config) vm.create() From 3b508fc87defe3338308b897d3a51fe39c8cb618 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 9 Feb 2020 09:36:50 +0100 Subject: [PATCH 220/284] phase in notion of a product --- uncloud/hack/main.py | 6 ++-- uncloud/hack/product.py | 80 +++++++++++++++++++++++++++++++++++++++++ uncloud/hack/vm.py | 18 ++++++++-- 3 files changed, 100 insertions(+), 4 deletions(-) create mode 100755 uncloud/hack/product.py diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index 391a5e4..b4717fd 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -55,6 +55,8 @@ def authenticate(username, password, totp_token=None): server = ldap3.Server("ldaps://ldap1.ungleich.ch") dn = "uid={},ou=customer,dc=ungleich,dc=ch".format(username) + log.debug("LDAP: connecting to {} as {}".format(server, dn)) + try: conn = ldap3.Connection(server, dn, password, auto_bind=True) except ldap3.core.exceptions.LDAPBindError as e: @@ -72,12 +74,12 @@ def order(config): if not config.arguments[required_arg]: raise UncloudException("Missing required argument: {}".format(required_arg)) - print(config.arguments) + log.debug(config.arguments) authenticate(config.arguments['username'], config.arguments['password']) # create DB entry for VM vm = VM(config) - vm.schedule() + vm.product.place_order() diff --git a/uncloud/hack/product.py b/uncloud/hack/product.py new file mode 100755 index 0000000..73f140a --- /dev/null +++ b/uncloud/hack/product.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# 2020 Nico Schottelius (nico.schottelius at ungleich.ch) +# +# This file is part of uncloud. +# +# uncloud is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# uncloud is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with uncloud. If not, see . + +import json +import uuid + +from uncloud import UncloudException +from uncloud.hack.db import DB + +# states + + +class Product(object): + def __init__(self, config, product_name, db_entry=None): + self.config = config + self.db = DB(self.config, prefix="/orders") + + self.db_entry = {} + self.db_entry["product_name"] = product_name + self.db_entry["db_version"] = 1 + + # Existing product? Read in db_entry + if db_entry: + self.db_entry = db_entry + + + @staticmethod + def define_feature(self, + name, + feature, + one_time_price, + recurring_price, + recurring_period, + minimum_period): + feature = {} + feature[name] = {} + + def valid_status(self): + if "status" in self.db_entry: + if self.db_entry["status"] in [ "NEW", "CREATED", "DELETED" ]: + return False + return True + + def validate_product(self): + if not "uuid" in self.db_entry: + self.db_entry["uuid"] = str(uuid.uuid4()) + if not "status" in self.db_entry: + self.db_entry["status"] = "NEW" + + def place_order(self): + """ Schedule creating the product in etcd """ + self.validate_product() + + # FIXME: very status + if not self.db_entry["status"] == "NEW": + raise UncloudException("Cannot re-order product") + + + + + + def __str__(self): + return self.features diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index b38d563..695e33b 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -41,6 +41,7 @@ import logging from uncloud.hack.db import DB from uncloud.hack.mac import MAC from uncloud.vmm import VMM +from uncloud.hack.product import Product log = logging.getLogger(__name__) log.setLevel(logging.DEBUG) @@ -71,11 +72,15 @@ class VM(object): self.image_format='qcow2' # External components. - self.vmm = VMM(vmm_backend=self.hackprefix) + + # This one is broken: + # TypeError: expected str, bytes or os.PathLike object, not NoneType + # Fix before re-enabling + # self.vmm = VMM(vmm_backend=self.hackprefix) self.mac = MAC(self.config) # Harcoded & generated values. - self.owner = 'uncoud' + self.owner = 'uncloud' self.accel = 'kvm' self.threads = 1 self.ifup = os.path.join(self.hackprefix, "ifup.sh") @@ -84,6 +89,12 @@ class VM(object): self.vm = {} + self.product = Product(config, product_name="dualstack-vm") + + self.features = [] +# self.features.append(self.define_feature( +# self.super().__init__( + def get_qemu_args(self): command = ( "-name {owner}-{name}" @@ -104,6 +115,9 @@ class VM(object): return command.split(" ") + def create_db_entry(self): + pass + def create(self): # New VM: new UUID, new MAC. self.uuid = str(uuid.uuid4()) From 5da6dbb32e481be479d410136178eb780cf3ef6c Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 9 Feb 2020 11:14:50 +0100 Subject: [PATCH 221/284] ++hack / list products Signed-off-by: Nico Schottelius --- uncloud/hack/db.py | 7 +++++++ uncloud/hack/main.py | 14 ++++++++++++++ uncloud/hack/product.py | 19 ++++++++++++++----- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/uncloud/hack/db.py b/uncloud/hack/db.py index 9086865..3e9a3c6 100644 --- a/uncloud/hack/db.py +++ b/uncloud/hack/db.py @@ -79,12 +79,19 @@ class DB(object): return value + @readable_errors + def get_prefix(self, key, as_json=False, **kwargs): + key_range = self._db_clients[0].get_prefix(self.realkey(key), **kwargs) + + return key_range + @readable_errors def set(self, key, value, as_json=False, **kwargs): if as_json: value = json.dumps(value) + log.debug("Setting {} = {}".format(self.realkey(key), value)) # FIXME: iterate over clients in case of failure ? return self._db_clients[0].put(self.realkey(key), value, **kwargs) diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index b4717fd..e3e6dc4 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -9,6 +9,7 @@ from uncloud.hack.mac import MAC from uncloud.hack.net import VXLANBridge, DNSRA from uncloud import UncloudException +from uncloud.hack.product import ProductOrder arg_parser = argparse.ArgumentParser('hack', add_help=False) #description="Commands that are unfinished - use at own risk") @@ -41,6 +42,7 @@ arg_parser.add_argument('--hackprefix', help="hackprefix, if you need it you kno # order based commands => later to be shifted below "order" arg_parser.add_argument('--order', action='store_true') +arg_parser.add_argument('--list-orders', help="List all orders", action='store_true') arg_parser.add_argument('--product', choices=["dualstack-vm"]) arg_parser.add_argument('--os-image-name', help="Name of OS image (successor to --image)") arg_parser.add_argument('--os-image-size', help="Size of OS image in GB", type=int, default=10) @@ -48,6 +50,9 @@ arg_parser.add_argument('--os-image-size', help="Size of OS image in GB", type=i arg_parser.add_argument('--username') arg_parser.add_argument('--password') +arg_parser.add_argument('--api', help="Run the API") + + log = logging.getLogger(__name__) @@ -79,6 +84,7 @@ def order(config): # create DB entry for VM vm = VM(config) + vm.product.db_entry["owner"] = config.arguments['username'] vm.product.place_order() @@ -86,9 +92,17 @@ def order(config): def main(arguments): config = Config(arguments) + if arguments['api']: + api = API() + api.run() + if arguments['order']: order(config) + if arguments['list_orders']: + p = ProductOrder(config) + p.list_orders() + if arguments['create_vm']: vm = VM(config) vm.create() diff --git a/uncloud/hack/product.py b/uncloud/hack/product.py index 73f140a..925fcdc 100755 --- a/uncloud/hack/product.py +++ b/uncloud/hack/product.py @@ -24,7 +24,14 @@ import uuid from uncloud import UncloudException from uncloud.hack.db import DB -# states +class ProductOrder(object): + def __init__(self, config): + self.config = config + self.db = DB(self.config, prefix="/orders") + + def list_orders(self, filter_key=None, filter_regexp_value=None): + for k,m in self.db.get_prefix(""): + print("{} {}".format(k,m)) class Product(object): @@ -54,7 +61,7 @@ class Product(object): def valid_status(self): if "status" in self.db_entry: - if self.db_entry["status"] in [ "NEW", "CREATED", "DELETED" ]: + if self.db_entry["status"] in [ "NEW", "SCHEDULED", "CREATED", "DELETED" ]: return False return True @@ -63,6 +70,9 @@ class Product(object): self.db_entry["uuid"] = str(uuid.uuid4()) if not "status" in self.db_entry: self.db_entry["status"] = "NEW" + if not "owner" in self.db_entry: + self.db_entry["owner"] = "UNKNOWN" + def place_order(self): """ Schedule creating the product in etcd """ @@ -72,9 +82,8 @@ class Product(object): if not self.db_entry["status"] == "NEW": raise UncloudException("Cannot re-order product") - - + self.db.set(self.db_entry["uuid"], str(self)) def __str__(self): - return self.features + return json.dumps(self.db_entry) From 5ef009cc9bb15b5bc9590eb59114976059d578cd Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 9 Feb 2020 12:12:15 +0100 Subject: [PATCH 222/284] Begin to phase in features and processing orders --- uncloud/hack/db.py | 8 ++- uncloud/hack/main.py | 13 ++++- uncloud/hack/product.py | 119 ++++++++++++++++++++++++++++------------ uncloud/hack/vm.py | 6 ++ 4 files changed, 108 insertions(+), 38 deletions(-) diff --git a/uncloud/hack/db.py b/uncloud/hack/db.py index 3e9a3c6..7798bd2 100644 --- a/uncloud/hack/db.py +++ b/uncloud/hack/db.py @@ -81,9 +81,13 @@ class DB(object): @readable_errors def get_prefix(self, key, as_json=False, **kwargs): - key_range = self._db_clients[0].get_prefix(self.realkey(key), **kwargs) + for value, meta in self._db_clients[0].get_prefix(self.realkey(key), **kwargs): + k = meta.key.decode("utf-8") + value = value.decode("utf-8") + if as_json: + value = json.loads(value) - return key_range + yield (k, value) @readable_errors diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index e3e6dc4..a76d210 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -43,6 +43,8 @@ arg_parser.add_argument('--hackprefix', help="hackprefix, if you need it you kno # order based commands => later to be shifted below "order" arg_parser.add_argument('--order', action='store_true') arg_parser.add_argument('--list-orders', help="List all orders", action='store_true') +arg_parser.add_argument('--process-orders', help="Process all (pending) orders", action='store_true') + arg_parser.add_argument('--product', choices=["dualstack-vm"]) arg_parser.add_argument('--os-image-name', help="Name of OS image (successor to --image)") arg_parser.add_argument('--os-image-size', help="Size of OS image in GB", type=int, default=10) @@ -51,6 +53,10 @@ arg_parser.add_argument('--username') arg_parser.add_argument('--password') arg_parser.add_argument('--api', help="Run the API") +arg_parser.add_argument('--mode', + choices=["direct", "api", "client"], + default="client", + help="Directly manipulate etcd, spawn the API server or behave as a client") @@ -101,7 +107,12 @@ def main(arguments): if arguments['list_orders']: p = ProductOrder(config) - p.list_orders() + for product_order in p.list_orders(): + print("Order {}: {}".format(product_order.db_entry['uuid'], product_order.db_entry)) + + if arguments['process_orders']: + p = ProductOrder(config) + p.process_orders() if arguments['create_vm']: vm = VM(config) diff --git a/uncloud/hack/product.py b/uncloud/hack/product.py index 925fcdc..97f64f0 100755 --- a/uncloud/hack/product.py +++ b/uncloud/hack/product.py @@ -20,52 +20,40 @@ import json import uuid +import logging from uncloud import UncloudException from uncloud.hack.db import DB +log = logging.getLogger(__name__) + class ProductOrder(object): - def __init__(self, config): + def __init__(self, config, product_entry=None, db_entry=None): self.config = config self.db = DB(self.config, prefix="/orders") - - def list_orders(self, filter_key=None, filter_regexp_value=None): - for k,m in self.db.get_prefix(""): - print("{} {}".format(k,m)) - - -class Product(object): - def __init__(self, config, product_name, db_entry=None): - self.config = config - self.db = DB(self.config, prefix="/orders") - self.db_entry = {} - self.db_entry["product_name"] = product_name self.db_entry["db_version"] = 1 + self.db_entry["product"] = product_entry - # Existing product? Read in db_entry + + # Overwrite if we are loading an existing product order if db_entry: self.db_entry = db_entry + # FIXME: this should return a list of our class! + def list_orders(self, filter_key=None, filter_regexp_value=None): + """List all orders with - filtering not yet implemented """ - @staticmethod - def define_feature(self, - name, - feature, - one_time_price, - recurring_price, - recurring_period, - minimum_period): - feature = {} - feature[name] = {} + for k,v in self.db.get_prefix("", as_json=True): + log.debug("{} {}".format(k,v)) - def valid_status(self): - if "status" in self.db_entry: - if self.db_entry["status"] in [ "NEW", "SCHEDULED", "CREATED", "DELETED" ]: - return False - return True + yield self.__class__(self.config, db_entry=v) - def validate_product(self): + def process_orders(self): + for orders in self.list_orders(): + pass + + def set_required_values(self): if not "uuid" in self.db_entry: self.db_entry["uuid"] = str(uuid.uuid4()) if not "status" in self.db_entry: @@ -73,14 +61,75 @@ class Product(object): if not "owner" in self.db_entry: self.db_entry["owner"] = "UNKNOWN" + def validate_status(self): + if "status" in self.db_entry: + if self.db_entry["status"] in [ "NEW", "SCHEDULED", "CREATED", "DELETED", "REJECTED" ]: + return False + return True + + def order(self): + if not self.db_entry["status"] == "NEW": + raise UncloudException("Cannot re-order same order. Status: {}".format(self.db_entry["status"])) + + +class Product(object): + def __init__(self, + config, + product_name, + db_entry=None): + self.config = config + self.db = DB(self.config, prefix="/orders") + + self.db_entry = {} + self.db_entry["product_name"] = product_name + self.db_entry["db_version"] = 1 + self.db_entry["features"] = {} + + # Existing product? Read in db_entry + if db_entry: + self.db_entry = db_entry + + self.valid_periods = [ "per_year", "per_month", "per_week", + "per_day", "per_hour", + "per_minute", "per_second" ] + + def define_feature(self, + name, + one_time_price, + recurring_price, + recurring_period, + minimum_period): + + self.db_entry['features'][name] = {} + self.db_entry['features'][name]['one_time_price'] = one_time_price + self.db_entry['features'][name]['recurring_price'] = recurring_price + + if not recurring_period in self.valid_periods: + raise UncloudException("Invalid recurring period: {}".format(recurring_period)) + + self.db_entry['features'][name]['recurring_period'] = recurring_period + + if not minimum_period in self.valid_periods: + raise UncloudException("Invalid recurring period: {}".format(recurring_period)) + + recurring_index = self.valid_periods.index(recurring_period) + minimum_index = self.valid_periods.index(minimum_period) + + if minimum_index < recurring_index: + raise UncloudException("Minimum period for product '{}' feature '{}' must be shorter or equal than/as recurring period: {} > {}".format(self.db_entry['product_name'], name, minimum_period, recurring_period)) + + self.db_entry['features'][name]['minimum_period'] = minimum_period + + + def validate_product(self): + for feature in self.db_entry['features']: + pass def place_order(self): """ Schedule creating the product in etcd """ - self.validate_product() - - # FIXME: very status - if not self.db_entry["status"] == "NEW": - raise UncloudException("Cannot re-order product") + order = ProductOrder(self.config, self.db_entry) + order.set_required_values() + order.order() self.db.set(self.db_entry["uuid"], str(self)) diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index 695e33b..981b519 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -90,6 +90,12 @@ class VM(object): self.vm = {} self.product = Product(config, product_name="dualstack-vm") + self.product.define_feature(name="base", + one_time_price=0, + recurring_price=9, + recurring_period="per_month", + minimum_period="per_hour") + self.features = [] # self.features.append(self.define_feature( From a80a279ba52ca65b829919fab5f6d7972aced719 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 9 Feb 2020 12:54:52 +0100 Subject: [PATCH 223/284] Add filtering support: (venv) [12:54] diamond:uncloud% ./bin/uncloud-run-reinstall hack --product 'dualstack-vm' --os-image-name alpine311 --username nicocustomer --password '...' --hackprefix ~/vcs/uncloud/uncloud/hack/hackcloud/ --etcd-host etcd1.ungleich.ch --etcd-ca-cert ~/vcs/ungleich-dot-cdist/files/etcd/ca.pem --etcd-cert-cert ~/vcs/ungleich-dot-cdist/files/etcd/nico.pem --etcd-cert-key ~/vcs/ungleich-dot-cdist/files/etcd/nico-key.pem --list-orders --filter-order-key "status" --filter-order-regexp NEW --- uncloud/hack/__init__.py | 1 + uncloud/hack/db.py | 10 ++++++++ uncloud/hack/main.py | 8 +++++- uncloud/hack/product.py | 55 +++++++++++++++++++++++++++++----------- uncloud/hack/vm.py | 1 + 5 files changed, 59 insertions(+), 16 deletions(-) diff --git a/uncloud/hack/__init__.py b/uncloud/hack/__init__.py index e69de29..8b13789 100644 --- a/uncloud/hack/__init__.py +++ b/uncloud/hack/__init__.py @@ -0,0 +1 @@ + diff --git a/uncloud/hack/db.py b/uncloud/hack/db.py index 7798bd2..a4395de 100644 --- a/uncloud/hack/db.py +++ b/uncloud/hack/db.py @@ -23,12 +23,20 @@ import etcd3 import json import logging +import datetime from functools import wraps from uncloud import UncloudException log = logging.getLogger(__name__) +def db_logentry(message): + timestamp = datetime.datetime.now() + return { + "timestamp": str(timestamp), + "message": message + } + def readable_errors(func): @wraps(func) @@ -99,6 +107,8 @@ class DB(object): # FIXME: iterate over clients in case of failure ? return self._db_clients[0].put(self.realkey(key), value, **kwargs) + + @readable_errors def increment(self, key, **kwargs): print(self.realkey(key)) diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index a76d210..c454b03 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -1,8 +1,10 @@ import argparse import logging +import re import ldap3 + from uncloud.hack.vm import VM from uncloud.hack.config import Config from uncloud.hack.mac import MAC @@ -43,6 +45,9 @@ arg_parser.add_argument('--hackprefix', help="hackprefix, if you need it you kno # order based commands => later to be shifted below "order" arg_parser.add_argument('--order', action='store_true') arg_parser.add_argument('--list-orders', help="List all orders", action='store_true') +arg_parser.add_argument('--filter-order-key', help="Which key to filter on") +arg_parser.add_argument('--filter-order-regexp', help="Which regexp the value should match") + arg_parser.add_argument('--process-orders', help="Process all (pending) orders", action='store_true') arg_parser.add_argument('--product', choices=["dualstack-vm"]) @@ -107,7 +112,8 @@ def main(arguments): if arguments['list_orders']: p = ProductOrder(config) - for product_order in p.list_orders(): + for product_order in p.list_orders(filter_key=arguments['filter_order_key'], + filter_regexp=arguments['filter_order_regexp']): print("Order {}: {}".format(product_order.db_entry['uuid'], product_order.db_entry)) if arguments['process_orders']: diff --git a/uncloud/hack/product.py b/uncloud/hack/product.py index 97f64f0..668b8ea 100755 --- a/uncloud/hack/product.py +++ b/uncloud/hack/product.py @@ -21,9 +21,10 @@ import json import uuid import logging +import re from uncloud import UncloudException -from uncloud.hack.db import DB +from uncloud.hack.db import DB, db_logentry log = logging.getLogger(__name__) @@ -32,38 +33,45 @@ class ProductOrder(object): self.config = config self.db = DB(self.config, prefix="/orders") self.db_entry = {} - self.db_entry["db_version"] = 1 self.db_entry["product"] = product_entry - # Overwrite if we are loading an existing product order if db_entry: self.db_entry = db_entry # FIXME: this should return a list of our class! - def list_orders(self, filter_key=None, filter_regexp_value=None): + def list_orders(self, filter_key=None, filter_regexp=None): """List all orders with - filtering not yet implemented """ for k,v in self.db.get_prefix("", as_json=True): log.debug("{} {}".format(k,v)) - - yield self.__class__(self.config, db_entry=v) - - def process_orders(self): - for orders in self.list_orders(): - pass + if filter_key and filter_regexp: + if filter_key in v: + if re.match(filter_regexp, v[filter_key]): + yield self.__class__(self.config, db_entry=v) + else: + yield self.__class__(self.config, db_entry=v) def set_required_values(self): + """Set values that are required to make the db entry valid""" if not "uuid" in self.db_entry: self.db_entry["uuid"] = str(uuid.uuid4()) if not "status" in self.db_entry: self.db_entry["status"] = "NEW" if not "owner" in self.db_entry: self.db_entry["owner"] = "UNKNOWN" + if not "log" in self.db_entry: + self.db_entry["log"] = [] + if not "db_version" in self.db_entry: + self.db_entry["db_version"] = 1 def validate_status(self): if "status" in self.db_entry: - if self.db_entry["status"] in [ "NEW", "SCHEDULED", "CREATED", "DELETED", "REJECTED" ]: + if self.db_entry["status"] in [ "NEW", + "SCHEDULED", + "CREATED_ACTIVE", + "CANCELLED", + "REJECTED" ]: return False return True @@ -71,6 +79,25 @@ class ProductOrder(object): if not self.db_entry["status"] == "NEW": raise UncloudException("Cannot re-order same order. Status: {}".format(self.db_entry["status"])) + def process_orders(self): + for order in self.list_orders(): + if order.db_entry["status"] == "NEW": + log.info("Handling new order: {}".format(order)) + + # FIXME: these all should be a transactions! -> fix concurrent access! ! + if not "log" in order.db_entry: + order.db_entry['log'] = [] + + for must_attribute in [ "owner", "product" ]: + if not must_attribute in order.db_entry: + order.db_entry['log'].append(db_logentry("Missing {} entry, rejecting order".format(must_attribute))) + order.db_entry['status'] = "REJECTED" + self.db.set(order.db_entry['uuid'], order.db_entry, as_json=True) + + + + def __str__(self): + return str(self.db_entry) class Product(object): def __init__(self, @@ -83,6 +110,7 @@ class Product(object): self.db_entry = {} self.db_entry["product_name"] = product_name self.db_entry["db_version"] = 1 + self.db_entry["log"] = [] self.db_entry["features"] = {} # Existing product? Read in db_entry @@ -127,12 +155,9 @@ class Product(object): def place_order(self): """ Schedule creating the product in etcd """ - order = ProductOrder(self.config, self.db_entry) + order = ProductOrder(self.config, product_entry=self.db_entry) order.set_required_values() order.order() - self.db.set(self.db_entry["uuid"], str(self)) - - def __str__(self): return json.dumps(self.db_entry) diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index 981b519..6bbe29a 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -101,6 +101,7 @@ class VM(object): # self.features.append(self.define_feature( # self.super().__init__( + def get_qemu_args(self): command = ( "-name {owner}-{name}" From b38c9b60606fe6c143fa30b82009e0b23b1ed6bb Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 9 Feb 2020 19:27:24 +0100 Subject: [PATCH 224/284] Ad capability to add and list hosts --- uncloud/hack/db.py | 12 +++++++ uncloud/hack/host.py | 75 +++++++++++++++++++++++++++++++++++++++++ uncloud/hack/main.py | 28 +++++++++++---- uncloud/hack/product.py | 69 ++++++++++++++++++++++++++++++------- uncloud/hack/vm.py | 13 ++++--- 5 files changed, 173 insertions(+), 24 deletions(-) create mode 100644 uncloud/hack/host.py diff --git a/uncloud/hack/db.py b/uncloud/hack/db.py index a4395de..3d5582e 100644 --- a/uncloud/hack/db.py +++ b/uncloud/hack/db.py @@ -24,6 +24,7 @@ import etcd3 import json import logging import datetime +import re from functools import wraps from uncloud import UncloudException @@ -108,6 +109,17 @@ class DB(object): return self._db_clients[0].put(self.realkey(key), value, **kwargs) + @readable_errors + def list_and_filter(self, key, filter_key=None, filter_regexp=None): + for k,v in self.get_prefix(key, as_json=True): + + if filter_key and filter_regexp: + if filter_key in v: + if re.match(filter_regexp, v[filter_key]): + yield v + else: + yield v + @readable_errors def increment(self, key, **kwargs): diff --git a/uncloud/hack/host.py b/uncloud/hack/host.py new file mode 100644 index 0000000..06ccf98 --- /dev/null +++ b/uncloud/hack/host.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# 2020 Nico Schottelius (nico.schottelius at ungleich.ch) +# +# This file is part of uncloud. +# +# uncloud is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# uncloud is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with uncloud. If not, see . + +import uuid + +from uncloud.hack.db import DB +from uncloud import UncloudException + +class Host(object): + def __init__(self, config, db_entry=None): + self.config = config + self.db = DB(self.config, prefix="/hosts") + + if db_entry: + self.db_entry = db_entry + + + def list_hosts(self, filter_key=None, filter_regexp=None): + """ Return list of all hosts """ + for entry in self.db.list_and_filter("", filter_key, filter_regexp): + yield self.__class__(self.config, db_entry=entry) + + def cmdline_add_host(self): + """ FIXME: make this a bit smarter and less redundant """ + + for required_arg in [ + 'add_vm_host', + 'max_cores_per_vm', + 'max_cores_total', + 'max_memory_in_gb' ]: + if not required_arg in self.config.arguments: + raise UncloudException("Missing argument: {}".format(required_arg)) + + return self.add_host( + self.config.arguments['add_vm_host'], + self.config.arguments['max_cores_per_vm'], + self.config.arguments['max_cores_total'], + self.config.arguments['max_memory_in_gb']) + + + def add_host(self, + hostname, + max_cores_per_vm, + max_cores_total, + max_memory_in_gb): + + db_entry = {} + db_entry['uuid'] = str(uuid.uuid4()) + db_entry['hostname'] = hostname + db_entry['max_cores_per_vm'] = max_cores_per_vm + db_entry['max_cores_total'] = max_cores_total + db_entry['max_memory_in_gb'] = max_memory_in_gb + db_entry["db_version"] = 1 + db_entry["log"] = [] + + self.db.set(db_entry['uuid'], db_entry, as_json=True) + + return self.__class__(self.config, db_entry) diff --git a/uncloud/hack/main.py b/uncloud/hack/main.py index c454b03..0ddd8fb 100644 --- a/uncloud/hack/main.py +++ b/uncloud/hack/main.py @@ -6,6 +6,7 @@ import ldap3 from uncloud.hack.vm import VM +from uncloud.hack.host import Host from uncloud.hack.config import Config from uncloud.hack.mac import MAC from uncloud.hack.net import VXLANBridge, DNSRA @@ -64,6 +65,13 @@ arg_parser.add_argument('--mode', help="Directly manipulate etcd, spawn the API server or behave as a client") +arg_parser.add_argument('--add-vm-host', help="Add a host that can run VMs") +arg_parser.add_argument('--list-vm-hosts', action='store_true') + +arg_parser.add_argument('--max-cores-per-vm') +arg_parser.add_argument('--max-cores-total') +arg_parser.add_argument('--max-memory-in-gb') + log = logging.getLogger(__name__) @@ -95,20 +103,28 @@ def order(config): # create DB entry for VM vm = VM(config) - vm.product.db_entry["owner"] = config.arguments['username'] - vm.product.place_order() + return vm.product.place_order(owner=config.arguments['username']) + + def main(arguments): config = Config(arguments) - if arguments['api']: - api = API() - api.run() + if arguments['add_vm_host']: + h = Host(config) + h.cmdline_add_host() + + if arguments['list_vm_hosts']: + h = Host(config) + + for host in h.list_hosts(filter_key=arguments['filter_order_key'], + filter_regexp=arguments['filter_order_regexp']): + print("Host {}: {}".format(host.db_entry['uuid'], host.db_entry)) if arguments['order']: - order(config) + print("Created order: {}".format(order(config))) if arguments['list_orders']: p = ProductOrder(config) diff --git a/uncloud/hack/product.py b/uncloud/hack/product.py index 668b8ea..f979268 100755 --- a/uncloud/hack/product.py +++ b/uncloud/hack/product.py @@ -22,6 +22,7 @@ import json import uuid import logging import re +import importlib from uncloud import UncloudException from uncloud.hack.db import DB, db_logentry @@ -41,16 +42,9 @@ class ProductOrder(object): # FIXME: this should return a list of our class! def list_orders(self, filter_key=None, filter_regexp=None): - """List all orders with - filtering not yet implemented """ + for entry in self.db.list_and_filter("", filter_key, filter_regexp): + yield self.__class__(self.config, db_entry=entry) - for k,v in self.db.get_prefix("", as_json=True): - log.debug("{} {}".format(k,v)) - if filter_key and filter_regexp: - if filter_key in v: - if re.match(filter_regexp, v[filter_key]): - yield self.__class__(self.config, db_entry=v) - else: - yield self.__class__(self.config, db_entry=v) def set_required_values(self): """Set values that are required to make the db entry valid""" @@ -76,10 +70,15 @@ class ProductOrder(object): return True def order(self): + self.set_required_values() if not self.db_entry["status"] == "NEW": raise UncloudException("Cannot re-order same order. Status: {}".format(self.db_entry["status"])) + self.db.set(self.db_entry["uuid"], self.db_entry, as_json=True) + + return self.db_entry["uuid"] def process_orders(self): + """processing orders can be done stand alone on server side""" for order in self.list_orders(): if order.db_entry["status"] == "NEW": log.info("Handling new order: {}".format(order)) @@ -88,12 +87,53 @@ class ProductOrder(object): if not "log" in order.db_entry: order.db_entry['log'] = [] + is_valid = True + # Verify the order entry for must_attribute in [ "owner", "product" ]: if not must_attribute in order.db_entry: - order.db_entry['log'].append(db_logentry("Missing {} entry, rejecting order".format(must_attribute))) + message = "Missing {} entry in order, rejecting order".format(must_attribute) + log.info("Rejecting order {}: {}".format(order.db_entry["uuid"], message)) + + order.db_entry['log'].append(db_logentry(message)) order.db_entry['status'] = "REJECTED" self.db.set(order.db_entry['uuid'], order.db_entry, as_json=True) + is_valid = False + + # Rejected the order + if not is_valid: + continue + + # Verify the product entry + for must_attribute in [ "python_product_class", "python_product_module" ]: + if not must_attribute in order.db_entry['product']: + message = "Missing {} entry in product of order, rejecting order".format(must_attribute) + log.info("Rejecting order {}: {}".format(order.db_entry["uuid"], message)) + + order.db_entry['log'].append(db_logentry(message)) + order.db_entry['status'] = "REJECTED" + self.db.set(order.db_entry['uuid'], order.db_entry, as_json=True) + + is_valid = False + + # Rejected the order + if not is_valid: + continue + + print(order.db_entry["product"]["python_product_class"]) + + # Create the product + m = importlib.import_module(order.db_entry["product"]["python_product_module"]) + c = getattr(m, order.db_entry["product"]["python_product_class"]) + + product = c(config, db_entry=order.db_entry["product"]) + + # STOPPED + product.create_product() + + order.db_entry['status'] = "SCHEDULED" + self.db.set(order.db_entry['uuid'], order.db_entry, as_json=True) + def __str__(self): @@ -103,12 +143,15 @@ class Product(object): def __init__(self, config, product_name, + product_class, db_entry=None): self.config = config self.db = DB(self.config, prefix="/orders") self.db_entry = {} self.db_entry["product_name"] = product_name + self.db_entry["python_product_class"] = product_class.__qualname__ + self.db_entry["python_product_module"] = product_class.__module__ self.db_entry["db_version"] = 1 self.db_entry["log"] = [] self.db_entry["features"] = {} @@ -153,11 +196,11 @@ class Product(object): for feature in self.db_entry['features']: pass - def place_order(self): + def place_order(self, owner): """ Schedule creating the product in etcd """ order = ProductOrder(self.config, product_entry=self.db_entry) - order.set_required_values() - order.order() + order.db_entry["owner"] = owner + return order.order() def __str__(self): return json.dumps(self.db_entry) diff --git a/uncloud/hack/vm.py b/uncloud/hack/vm.py index 6bbe29a..4b0ca14 100755 --- a/uncloud/hack/vm.py +++ b/uncloud/hack/vm.py @@ -47,7 +47,7 @@ log = logging.getLogger(__name__) log.setLevel(logging.DEBUG) class VM(object): - def __init__(self, config): + def __init__(self, config, db_entry=None): self.config = config #TODO: Enable etcd lookup @@ -55,6 +55,9 @@ class VM(object): if not self.no_db: self.db = DB(self.config, prefix="/vm") + if db_entry: + self.db_entry = db_entry + # General CLI arguments. self.hackprefix = self.config.arguments['hackprefix'] self.uuid = self.config.arguments['uuid'] @@ -89,7 +92,8 @@ class VM(object): self.vm = {} - self.product = Product(config, product_name="dualstack-vm") + self.product = Product(config, product_name="dualstack-vm", + product_class=self.__class__) self.product.define_feature(name="base", one_time_price=0, recurring_price=9, @@ -98,8 +102,6 @@ class VM(object): self.features = [] -# self.features.append(self.define_feature( -# self.super().__init__( def get_qemu_args(self): @@ -122,7 +124,8 @@ class VM(object): return command.split(" ") - def create_db_entry(self): + def create_product(self): + """Find a VM host and schedule on it""" pass def create(self): From 929211162dc2c9ea930ea900e96a0f2c5b5a867b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 5 Mar 2020 10:23:34 +0100 Subject: [PATCH 225/284] Replace legacy Stripe Charge API by Payment{setup, intent} --- uncloud/uncloud/urls.py | 1 - .../migrations/0017_auto_20200304_1723.py | 17 +++++ .../migrations/0018_auto_20200305_0819.py | 13 ++++ .../migrations/0019_auto_20200305_0851.py | 23 ++++++ .../migrations/0020_auto_20200305_0911.py | 18 +++++ uncloud/uncloud_pay/models.py | 27 ++++--- uncloud/uncloud_pay/serializers.py | 7 +- uncloud/uncloud_pay/stripe.py | 42 ++++++---- .../templates/stripe-payment.html.j2 | 76 +++++++++++++++++++ uncloud/uncloud_pay/views.py | 76 ++++++++++++++----- 10 files changed, 250 insertions(+), 50 deletions(-) create mode 100644 uncloud/uncloud_pay/migrations/0017_auto_20200304_1723.py create mode 100644 uncloud/uncloud_pay/migrations/0018_auto_20200305_0819.py create mode 100644 uncloud/uncloud_pay/migrations/0019_auto_20200305_0851.py create mode 100644 uncloud/uncloud_pay/migrations/0020_auto_20200305_0911.py create mode 100644 uncloud/uncloud_pay/templates/stripe-payment.html.j2 diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index d7ee153..e42bb7e 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -61,7 +61,6 @@ router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='paym router.register(r'bill', payviews.BillViewSet, basename='bill') router.register(r'order', payviews.OrderViewSet, basename='order') router.register(r'payment', payviews.PaymentViewSet, basename='payment') -router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='payment-methods') # VMs router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vm') diff --git a/uncloud/uncloud_pay/migrations/0017_auto_20200304_1723.py b/uncloud/uncloud_pay/migrations/0017_auto_20200304_1723.py new file mode 100644 index 0000000..3321e66 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0017_auto_20200304_1723.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.4 on 2020-03-04 17:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0016_auto_20200303_1552'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='paymentmethod', + unique_together=set(), + ), + ] diff --git a/uncloud/uncloud_pay/migrations/0018_auto_20200305_0819.py b/uncloud/uncloud_pay/migrations/0018_auto_20200305_0819.py new file mode 100644 index 0000000..e0f9087 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0018_auto_20200305_0819.py @@ -0,0 +1,13 @@ +# Generated by Django 3.0.4 on 2020-03-05 08:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0017_auto_20200304_1723'), + ] + + operations = [ + ] diff --git a/uncloud/uncloud_pay/migrations/0019_auto_20200305_0851.py b/uncloud/uncloud_pay/migrations/0019_auto_20200305_0851.py new file mode 100644 index 0000000..d8a7c22 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0019_auto_20200305_0851.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.4 on 2020-03-05 08:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0018_auto_20200305_0819'), + ] + + operations = [ + migrations.AddField( + model_name='paymentmethod', + name='stripe_setup_intent_id', + field=models.CharField(blank=True, max_length=32, null=True), + ), + migrations.AlterField( + model_name='paymentmethod', + name='stripe_card_id', + field=models.CharField(blank=True, max_length=32, null=True), + ), + ] diff --git a/uncloud/uncloud_pay/migrations/0020_auto_20200305_0911.py b/uncloud/uncloud_pay/migrations/0020_auto_20200305_0911.py new file mode 100644 index 0000000..9e1b677 --- /dev/null +++ b/uncloud/uncloud_pay/migrations/0020_auto_20200305_0911.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.4 on 2020-03-05 09:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0019_auto_20200305_0851'), + ] + + operations = [ + migrations.RenameField( + model_name='paymentmethod', + old_name='stripe_card_id', + new_name='stripe_payment_method_id', + ), + ] diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 43064e4..e209dbb 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -119,27 +119,31 @@ class PaymentMethod(models.Model): primary = models.BooleanField(default=True) # Only used for "Stripe" source - stripe_card_id = models.CharField(max_length=32, blank=True, null=True) + stripe_payment_method_id = models.CharField(max_length=32, blank=True, null=True) + stripe_setup_intent_id = models.CharField(max_length=32, blank=True, null=True) @property def stripe_card_last4(self): - if self.source == 'stripe': - card_request = uncloud_pay.stripe.get_card( - StripeCustomer.objects.get(owner=self.owner).stripe_id, - self.stripe_card_id) - if card_request['error'] == None: - return card_request['response_object']['last4'] - else: - return None + if self.source == 'stripe' and self.active: + payment_method = uncloud_pay.stripe.get_payment_method( + self.stripe_payment_method_id) + return payment_method.card.last4 else: return None + @property + def active(self): + if self.source == 'stripe' and self.stripe_payment_method_id != None: + return True + else: + return False def charge(self, amount): if amount > 0: # Make sure we don't charge negative amount by errors... if self.source == 'stripe': stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id - charge_request = uncloud_pay.stripe.charge_customer(amount, stripe_customer, self.stripe_card_id) + charge_request = uncloud_pay.stripe.charge_customer( + amount, stripe_customer, self.stripe_payment_method_id) if charge_request['error'] == None: payment = Payment(owner=self.owner, source=self.source, amount=amount) payment.save() # TODO: Check return status @@ -163,7 +167,8 @@ class PaymentMethod(models.Model): return None class Meta: - unique_together = [['owner', 'primary']] + #API_keyunique_together = [['owner', 'primary']] + pass ### # Bills & Payments. diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index aa75fd9..44402b4 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -29,7 +29,7 @@ class PaymentMethodSerializer(serializers.ModelSerializer): class Meta: model = PaymentMethod - fields = ['uuid', 'source', 'description', 'primary', 'stripe_card_last4'] + fields = ['uuid', 'source', 'description', 'primary', 'stripe_card_last4', 'active'] class ChargePaymentMethodSerializer(serializers.Serializer): amount = serializers.DecimalField(max_digits=10, decimal_places=2) @@ -41,11 +41,10 @@ class CreditCardSerializer(serializers.Serializer): cvc = serializers.IntegerField() class CreatePaymentMethodSerializer(serializers.ModelSerializer): - credit_card = CreditCardSerializer() - + please_visit = serializers.CharField(read_only=True) class Meta: model = PaymentMethod - fields = ['source', 'description', 'primary', 'credit_card'] + fields = ['source', 'description', 'primary', 'please_visit'] ### diff --git a/uncloud/uncloud_pay/stripe.py b/uncloud/uncloud_pay/stripe.py index 4f28d94..72399c8 100644 --- a/uncloud/uncloud_pay/stripe.py +++ b/uncloud/uncloud_pay/stripe.py @@ -10,6 +10,10 @@ import uncloud.secrets # Static stripe configuration used below. CURRENCY = 'chf' +# README: We use the Payment Intent API as described on +# https://stripe.com/docs/payments/save-and-reuse + +# For internal use only. stripe.api_key = uncloud.secrets.STRIPE_KEY # Helper (decorator) used to catch errors raised by stripe logic. @@ -82,6 +86,9 @@ class CreditCard(): # Actual Stripe logic. +def public_api_key(): + return uncloud.settings.STRIPE_PUBLIC_KEY + def get_customer_id_for(user): try: # .get() raise if there is no matching entry. @@ -99,15 +106,17 @@ def get_customer_id_for(user): return None @handle_stripe_error -def create_card(customer_id, credit_card): - return stripe.Customer.create_source( - customer_id, - card={ - 'number': credit_card.number, - 'exp_month': credit_card.exp_month, - 'exp_year': credit_card.exp_year, - 'cvc': credit_card.cvc - }) +def create_setup_intent(customer_id): + return stripe.SetupIntent.create(customer=customer_id) + +@handle_stripe_error +def get_setup_intent(setup_intent_id): + return stripe.SetupIntent.retrieve(setup_intent_id) + +def get_payment_method(payment_method_id): + return stripe.PaymentMethod.retrieve(payment_method_id) + +## Legacy @handle_stripe_error def get_card(customer_id, card_id): @@ -116,13 +125,16 @@ def get_card(customer_id, card_id): @handle_stripe_error def charge_customer(amount, customer_id, card_id): # Amount is in CHF but stripes requires smallest possible unit. - # See https://stripe.com/docs/api/charges/create + # https://stripe.com/docs/api/payment_intents/create#create_payment_intent-amount adjusted_amount = int(amount * 100) - return stripe.Charge.create( - amount=adjusted_amount, - currency=CURRENCY, - customer=customer_id, - source=card_id) + return stripe.PaymentIntent.create( + amount=adjusted_amount, + currency=CURRENCY, + customer=customer_id, + payment_method=card_id, + off_session=True, + confirm=True, + ) @handle_stripe_error def create_customer(name, email): diff --git a/uncloud/uncloud_pay/templates/stripe-payment.html.j2 b/uncloud/uncloud_pay/templates/stripe-payment.html.j2 new file mode 100644 index 0000000..6c59740 --- /dev/null +++ b/uncloud/uncloud_pay/templates/stripe-payment.html.j2 @@ -0,0 +1,76 @@ + + + + Stripe Card Registration + + + + + + + + +
+

Registering Stripe Credit Card

+ + + +
+
+ +
+ + +
+
+ + + + + + + + diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index 38d1aa4..32e7238 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -1,9 +1,12 @@ from django.shortcuts import render from django.db import transaction from django.contrib.auth import get_user_model -from rest_framework import viewsets, permissions, status +from rest_framework import viewsets, permissions, status, views +from rest_framework.renderers import TemplateHTMLRenderer from rest_framework.response import Response from rest_framework.decorators import action +from rest_framework.reverse import reverse +from rest_framework.decorators import renderer_classes import json @@ -69,7 +72,6 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): else: return PaymentMethodSerializer - def get_queryset(self): return PaymentMethod.objects.filter(owner=self.request.user) @@ -78,29 +80,32 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): def create(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + payment_method = PaymentMethod.objects.create(owner=request.user, **serializer.validated_data) - # Retrieve Stripe customer ID for user. - customer_id = uncloud_stripe.get_customer_id_for(request.user) - if customer_id == None: - return Response( + if serializer.validated_data['source'] == "stripe": + # Retrieve Stripe customer ID for user. + customer_id = uncloud_stripe.get_customer_id_for(request.user) + if customer_id == None: + return Response( {'error': 'Could not resolve customer stripe ID.'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - # Register card under stripe customer. - credit_card = uncloud_stripe.CreditCard(**serializer.validated_data.pop('credit_card')) - card_request = uncloud_stripe.create_card(customer_id, credit_card) - if card_request['error']: - return Response({'stripe_error': card_request['error']}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - card_id = card_request['response_object']['id'] + # TODO: handle error + setup_intent = uncloud_stripe.create_setup_intent(customer_id) + payment_method = PaymentMethod.objects.create( + owner=request.user, + stripe_setup_intent_id=setup_intent['response_object']['id'], + **serializer.validated_data) - # Save payment method locally. - serializer.validated_data['stripe_card_id'] = card_request['response_object']['id'] - payment_method = PaymentMethod.objects.create(owner=request.user, **serializer.validated_data) + # TODO: find a way to use reverse properly: + # https://www.django-rest-framework.org/api-guide/reverse/ + query= "payment-method/{}/register-stripe-cc".format( + payment_method.uuid + ) + stripe_registration_url = reverse('api-root', request=request) + query + return Response({'please_visit': stripe_registration_url}) - # We do not want to return the credit card details sent with the POST - # request. - output_serializer = PaymentMethodSerializer(payment_method) - return Response(output_serializer.data) + return Response(serializer.data) @action(detail=True, methods=['post']) def charge(self, request, pk=None): @@ -115,6 +120,39 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): except Exception as e: return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + @action(detail=True, methods=['get'], url_path='register-stripe-cc', renderer_classes=[TemplateHTMLRenderer]) + def register_stripe_cc(self, request, pk=None): + payment_method = self.get_object() + setup_intent = uncloud_stripe.get_setup_intent( + payment_method.stripe_setup_intent_id) + + # Render stripe card registration form. + template_args = { + 'client_secret': setup_intent["response_object"]["client_secret"], + 'stripe_pk': uncloud_stripe.public_api_key + } + return Response(template_args, template_name='stripe-payment.html.j2') + + @action(detail=True, methods=['post'], url_path='register-stripe-cc') + def register_stripe_cc(self, request, pk=None): + payment_method = self.get_object() + setup_intent = uncloud_stripe.get_setup_intent( + payment_method.stripe_setup_intent_id) + + # Card had been registered, fetching payment method. + payment_method_id = setup_intent["response_object"].payment_method + if payment_method_id: + payment_method.stripe_payment_method_id = payment_method_id + payment_method.save() + + return Response({ + 'uuid': payment_method.uuid, + 'activated': payment_method.active}) + else: + error = 'Could not fetch payment method from stripe. Please try again.' + return Response({'error': error}) + + ### # Admin views. From 7e9f2ea5614a9580458ce588810afbe99162a591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 5 Mar 2020 10:27:33 +0100 Subject: [PATCH 226/284] Cleanup/reorder uncloud_pay views --- uncloud/uncloud_pay/models.py | 2 +- uncloud/uncloud_pay/views.py | 71 ++++++++++++++++------------------- 2 files changed, 34 insertions(+), 39 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index e209dbb..8c9fc76 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -171,7 +171,7 @@ class PaymentMethod(models.Model): pass ### -# Bills & Payments. +# Bills. class Bill(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index 32e7238..a22c616 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -16,43 +16,7 @@ from datetime import datetime import uncloud_pay.stripe as uncloud_stripe ### -# Standard user views: - -class BalanceViewSet(viewsets.ViewSet): - # here we return a number - # number = sum(payments) - sum(bills) - - #bills = Bill.objects.filter(owner=self.request.user) - #payments = Payment.objects.filter(owner=self.request.user) - - # sum_paid = sum([ amount for amount payments..,. ]) # you get the picture - # sum_to_be_paid = sum([ amount for amount bills..,. ]) # you get the picture - pass - - -class BillViewSet(viewsets.ReadOnlyModelViewSet): - serializer_class = BillSerializer - permission_classes = [permissions.IsAuthenticated] - - def get_queryset(self): - return Bill.objects.filter(owner=self.request.user) - - def unpaid(self, request): - return Bill.objects.filter(owner=self.request.user, paid=False) - -class PaymentViewSet(viewsets.ReadOnlyModelViewSet): - serializer_class = PaymentSerializer - permission_classes = [permissions.IsAuthenticated] - - def get_queryset(self): - return Payment.objects.filter(owner=self.request.user) - -class OrderViewSet(viewsets.ReadOnlyModelViewSet): - serializer_class = OrderSerializer - permission_classes = [permissions.IsAuthenticated] - - def get_queryset(self): - return Order.objects.filter(owner=self.request.user) +# Users. class UserViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = UserSerializer @@ -61,6 +25,16 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return get_user_model().objects.all() +### +# Payments and Payment Methods. + +class PaymentViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = PaymentSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return Payment.objects.filter(owner=self.request.user) + class PaymentMethodViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] @@ -152,9 +126,30 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): error = 'Could not fetch payment method from stripe. Please try again.' return Response({'error': error}) +### +# Bills and Orders. + +class BillViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = BillSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return Bill.objects.filter(owner=self.request.user) + + def unpaid(self, request): + return Bill.objects.filter(owner=self.request.user, paid=False) + + +class OrderViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = OrderSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return Order.objects.filter(owner=self.request.user) + ### -# Admin views. +# Old admin stuff. class AdminPaymentViewSet(viewsets.ModelViewSet): serializer_class = PaymentSerializer From 08bf7cd3204b9e13c1522a659192143d9582d6bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 5 Mar 2020 10:28:03 +0100 Subject: [PATCH 227/284] Add STRIPE_PUBLIC_KEY setting --- uncloud/uncloud/settings.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index cc0ec3a..9f1ac91 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -172,3 +172,7 @@ USE_TZ = True # https://docs.djangoproject.com/en/3.0/howto/static-files/ STATIC_URL = '/static/' + +################################################################################ +# Stripe +STRIPE_PUBLIC_KEY="" From 4cc19e1e6e5fbf11f3f5c5e1f744eb83b5da78ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 5 Mar 2020 10:28:50 +0100 Subject: [PATCH 228/284] Remove legacy credit card support --- uncloud/uncloud_pay/serializers.py | 6 ------ uncloud/uncloud_pay/stripe.py | 19 ------------------- 2 files changed, 25 deletions(-) diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 44402b4..d406493 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -34,12 +34,6 @@ class PaymentMethodSerializer(serializers.ModelSerializer): class ChargePaymentMethodSerializer(serializers.Serializer): amount = serializers.DecimalField(max_digits=10, decimal_places=2) -class CreditCardSerializer(serializers.Serializer): - number = serializers.IntegerField() - exp_month = serializers.IntegerField() - exp_year = serializers.IntegerField() - cvc = serializers.IntegerField() - class CreatePaymentMethodSerializer(serializers.ModelSerializer): please_visit = serializers.CharField(read_only=True) class Meta: diff --git a/uncloud/uncloud_pay/stripe.py b/uncloud/uncloud_pay/stripe.py index 72399c8..1d745ef 100644 --- a/uncloud/uncloud_pay/stripe.py +++ b/uncloud/uncloud_pay/stripe.py @@ -71,19 +71,6 @@ def handle_stripe_error(f): return handle_problems -# Convenience CC container, also used for serialization. -class CreditCard(): - number = None - exp_year = None - exp_month = None - cvc = None - - def __init__(self, number, exp_month, exp_year, cvc): - self.number=number - self.exp_year = exp_year - self.exp_month = exp_month - self.cvc = cvc - # Actual Stripe logic. def public_api_key(): @@ -116,12 +103,6 @@ def get_setup_intent(setup_intent_id): def get_payment_method(payment_method_id): return stripe.PaymentMethod.retrieve(payment_method_id) -## Legacy - -@handle_stripe_error -def get_card(customer_id, card_id): - return stripe.Customer.retrieve_source(customer_id, card_id) - @handle_stripe_error def charge_customer(amount, customer_id, card_id): # Amount is in CHF but stripes requires smallest possible unit. From 21e1a3d220f4771e2e7e6c59460ee08231d9256b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 5 Mar 2020 11:03:47 +0100 Subject: [PATCH 229/284] Revamp stripe error handling --- uncloud/uncloud_pay/models.py | 37 +++++++----- uncloud/uncloud_pay/stripe.py | 46 ++++++-------- uncloud/uncloud_pay/templates/error.html.j2 | 18 ++++++ uncloud/uncloud_pay/views.py | 66 +++++++++++++++------ 4 files changed, 104 insertions(+), 63 deletions(-) create mode 100644 uncloud/uncloud_pay/templates/error.html.j2 diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 8c9fc76..c8aba99 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -139,35 +139,40 @@ class PaymentMethod(models.Model): return False def charge(self, amount): - if amount > 0: # Make sure we don't charge negative amount by errors... - if self.source == 'stripe': - stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id - charge_request = uncloud_pay.stripe.charge_customer( - amount, stripe_customer, self.stripe_payment_method_id) - if charge_request['error'] == None: - payment = Payment(owner=self.owner, source=self.source, amount=amount) - payment.save() # TODO: Check return status + if not self.active: + raise Exception('This payment method is inactive.') - return payment - else: - raise Exception('Stripe error: {}'.format(charge_request['error'])) - else: - raise Exception('This payment method is unsupported/cannot be charged.') - else: + if amount > 0: # Make sure we don't charge negative amount by errors... raise Exception('Cannot charge negative amount.') + if self.source == 'stripe': + stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id + stripe_payment = uncloud_pay.stripe.charge_customer( + amount, stripe_customer, self.stripe_payment_method_id) + if stripe_payment['paid']: + payment = Payment(owner=self.owner, source=self.source, amount=amount) + payment.save() # TODO: Check return status + + return payment + else: + raise Exception(stripe_payment['error']) + else: + raise Exception('This payment method is unsupported/cannot be charged.') + def get_primary_for(user): methods = PaymentMethod.objects.filter(owner=user) for method in methods: # Do we want to do something with non-primary method? - if method.primary: + if method.active and method.primary: return method return None class Meta: - #API_keyunique_together = [['owner', 'primary']] + # TODO: limit to one primary method per user. + # unique_together is no good since it won't allow more than one + # non-primary method. pass ### diff --git a/uncloud/uncloud_pay/stripe.py b/uncloud/uncloud_pay/stripe.py index 1d745ef..7dc53c6 100644 --- a/uncloud/uncloud_pay/stripe.py +++ b/uncloud/uncloud_pay/stripe.py @@ -17,6 +17,7 @@ CURRENCY = 'chf' stripe.api_key = uncloud.secrets.STRIPE_KEY # Helper (decorator) used to catch errors raised by stripe logic. +# Catch errors that should not be displayed to the end user, raise again. def handle_stripe_error(f): def handle_problems(*args, **kwargs): response = { @@ -25,49 +26,38 @@ def handle_stripe_error(f): 'error': None } - common_message = "Currently it is not possible to make payments." + common_message = "Currently it is not possible to make payments. Please try agin later." try: response_object = f(*args, **kwargs) - response = { - 'response_object': response_object, - 'error': None - } - return response + return response_object except stripe.error.CardError as e: # Since it's a decline, stripe.error.CardError will be caught body = e.json_body - err = body['error'] - response.update({'error': err['message']}) logging.error(str(e)) - return response + + raise e # For error handling. except stripe.error.RateLimitError: - response.update( - {'error': "Too many requests made to the API too quickly"}) - return response + logging.error("Too many requests made to the API too quickly.") + raise Exception(common_message) except stripe.error.InvalidRequestError as e: logging.error(str(e)) - response.update({'error': "Invalid parameters"}) - return response + raise Exception('Invalid parameters.') except stripe.error.AuthenticationError as e: # Authentication with Stripe's API failed # (maybe you changed API keys recently) logging.error(str(e)) - response.update({'error': common_message}) - return response + raise Exception(common_message) except stripe.error.APIConnectionError as e: logging.error(str(e)) - response.update({'error': common_message}) - return response + raise Exception(common_message) except stripe.error.StripeError as e: - # maybe send email + # XXX: maybe send email logging.error(str(e)) - response.update({'error': common_message}) - return response + raise Exception(common_message) except Exception as e: # maybe send email logging.error(str(e)) - response.update({'error': common_message}) - return response + raise Exception(common_message) return handle_problems @@ -82,14 +72,14 @@ def get_customer_id_for(user): return uncloud_pay.models.StripeCustomer.objects.get(owner=user).stripe_id except ObjectDoesNotExist: # No entry yet - making a new one. - customer_request = create_customer(user.username, user.email) - if customer_request['error'] == None: - mapping = uncloud_pay.models.StripeCustomer.objects.create( + try: + customer = create_customer(user.username, user.email) + uncloud_stripe_mapping = uncloud_pay.models.StripeCustomer.objects.create( owner=user, stripe_id=customer_request['response_object']['id'] ) - return mapping.stripe_id - else: + return uncloud_stripe_mapping.stripe_id + except Exception as e: return None @handle_stripe_error diff --git a/uncloud/uncloud_pay/templates/error.html.j2 b/uncloud/uncloud_pay/templates/error.html.j2 new file mode 100644 index 0000000..ba9209c --- /dev/null +++ b/uncloud/uncloud_pay/templates/error.html.j2 @@ -0,0 +1,18 @@ + + + + Error + + + +
+

Error

+

{{ error }}

+
+ + diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index a22c616..08e94a0 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -58,25 +58,28 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): if serializer.validated_data['source'] == "stripe": # Retrieve Stripe customer ID for user. - customer_id = uncloud_stripe.get_customer_id_for(request.user) + customer_id = uncloud_stripe.get_customer_id_for(request.user) if customer_id == None: return Response( {'error': 'Could not resolve customer stripe ID.'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - # TODO: handle error - setup_intent = uncloud_stripe.create_setup_intent(customer_id) + try: + setup_intent = uncloud_stripe.create_setup_intent(customer_id) + except Exception as e: + return Response({'error': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR) + payment_method = PaymentMethod.objects.create( owner=request.user, - stripe_setup_intent_id=setup_intent['response_object']['id'], + stripe_setup_intent_id=setup_intent.id, **serializer.validated_data) # TODO: find a way to use reverse properly: # https://www.django-rest-framework.org/api-guide/reverse/ - query= "payment-method/{}/register-stripe-cc".format( - payment_method.uuid - ) - stripe_registration_url = reverse('api-root', request=request) + query + path = "payment-method/{}/register-stripe-cc".format( + payment_method.uuid) + stripe_registration_url = reverse('api-root', request=request) + path return Response({'please_visit': stripe_registration_url}) return Response(serializer.data) @@ -97,26 +100,51 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): @action(detail=True, methods=['get'], url_path='register-stripe-cc', renderer_classes=[TemplateHTMLRenderer]) def register_stripe_cc(self, request, pk=None): payment_method = self.get_object() - setup_intent = uncloud_stripe.get_setup_intent( - payment_method.stripe_setup_intent_id) + if payment_method.source != 'stripe': + return Response( + {'error': 'This is not a Stripe-based payment method.'}, + template_name='error.html.j2') + + if payment_method.active: + return Response( + {'error': 'This payment method is already active'}, + template_name='error.html.j2') + + try: + setup_intent = uncloud_stripe.get_setup_intent( + payment_method.stripe_setup_intent_id) + except Exception as e: + return Response( + {'error': str(e)}, + template_name='error.html.j2') + + # TODO: find a way to use reverse properly: + # https://www.django-rest-framework.org/api-guide/reverse/ + callback_path= "payment-method/{}/activate-stripe-cc/".format( + payment_method.uuid) + callback = reverse('api-root', request=request) + callback_path # Render stripe card registration form. template_args = { - 'client_secret': setup_intent["response_object"]["client_secret"], - 'stripe_pk': uncloud_stripe.public_api_key + 'client_secret': setup_intent.client_secret, + 'stripe_pk': uncloud_stripe.public_api_key, + 'callback': callback } return Response(template_args, template_name='stripe-payment.html.j2') - @action(detail=True, methods=['post'], url_path='register-stripe-cc') - def register_stripe_cc(self, request, pk=None): + @action(detail=True, methods=['post'], url_path='activate-stripe-cc') + def activate_stripe_cc(self, request, pk=None): payment_method = self.get_object() - setup_intent = uncloud_stripe.get_setup_intent( - payment_method.stripe_setup_intent_id) + try: + setup_intent = uncloud_stripe.get_setup_intent( + payment_method.stripe_setup_intent_id) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) # Card had been registered, fetching payment method. - payment_method_id = setup_intent["response_object"].payment_method - if payment_method_id: - payment_method.stripe_payment_method_id = payment_method_id + print(setup_intent) + if setup_intent.payment_method: + payment_method.stripe_payment_method_id = setup_intent.payment_method payment_method.save() return Response({ From 2f70418f4d6f9b6393611a563ccc339f4abc1838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 5 Mar 2020 11:13:04 +0100 Subject: [PATCH 230/284] Fix dumb logic errors/typo from last commit --- uncloud/uncloud_pay/models.py | 13 +++++++------ uncloud/uncloud_pay/serializers.py | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index c8aba99..0ac4107 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -142,20 +142,21 @@ class PaymentMethod(models.Model): if not self.active: raise Exception('This payment method is inactive.') - if amount > 0: # Make sure we don't charge negative amount by errors... + if amount < 0: # Make sure we don't charge negative amount by errors... raise Exception('Cannot charge negative amount.') if self.source == 'stripe': stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id stripe_payment = uncloud_pay.stripe.charge_customer( amount, stripe_customer, self.stripe_payment_method_id) - if stripe_payment['paid']: - payment = Payment(owner=self.owner, source=self.source, amount=amount) - payment.save() # TODO: Check return status + print(stripe_payment) + if 'paid' in stripe_payment and stripe_payment['paid'] == False: + raise Exception(stripe_payment['error']) + else: + payment = Payment.objects.create( + owner=self.owner, source=self.source, amount=amount) return payment - else: - raise Exception(stripe_payment['error']) else: raise Exception('This payment method is unsupported/cannot be charged.') diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index d406493..bfbe0da 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -22,7 +22,7 @@ class UserSerializer(serializers.ModelSerializer): class PaymentSerializer(serializers.ModelSerializer): class Meta: model = Payment - fields = ['owner', 'amount', 'source', 'timestamp'] + fields = '__all__' class PaymentMethodSerializer(serializers.ModelSerializer): stripe_card_last4 = serializers.IntegerField() From a41184d83d23d8344060c881c282e452d7407653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 5 Mar 2020 11:27:43 +0100 Subject: [PATCH 231/284] Fix generate-bills, remove debug print in charge method --- uncloud/uncloud_pay/management/commands/generate-bills.py | 5 +---- uncloud/uncloud_pay/models.py | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/uncloud/uncloud_pay/management/commands/generate-bills.py b/uncloud/uncloud_pay/management/commands/generate-bills.py index a7dbe78..5bd4519 100644 --- a/uncloud/uncloud_pay/management/commands/generate-bills.py +++ b/uncloud/uncloud_pay/management/commands/generate-bills.py @@ -9,8 +9,6 @@ from datetime import timedelta, date from django.utils import timezone from uncloud_pay.models import Bill -BILL_PAYMENT_DELAY=timedelta(days=10) - logger = logging.getLogger(__name__) class Command(BaseCommand): @@ -31,8 +29,7 @@ class Command(BaseCommand): Bill.generate_for( year=now.year, month=now.month, - user=user, - allowed_delay=BILL_PAYMENT_DELAY) + user=user) # We're done for this round :-) print("=> Done.") diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 0ac4107..ac91034 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -149,7 +149,6 @@ class PaymentMethod(models.Model): stripe_customer = StripeCustomer.objects.get(owner=self.owner).stripe_id stripe_payment = uncloud_pay.stripe.charge_customer( amount, stripe_customer, self.stripe_payment_method_id) - print(stripe_payment) if 'paid' in stripe_payment and stripe_payment['paid'] == False: raise Exception(stripe_payment['error']) else: From d6ee806467d5b19478b1bf06528dc32b1566bcd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 5 Mar 2020 11:28:20 +0100 Subject: [PATCH 232/284] Fix error in stripe get_customer_id_for --- uncloud/uncloud_pay/stripe.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/uncloud/uncloud_pay/stripe.py b/uncloud/uncloud_pay/stripe.py index 7dc53c6..ab3eac2 100644 --- a/uncloud/uncloud_pay/stripe.py +++ b/uncloud/uncloud_pay/stripe.py @@ -75,9 +75,7 @@ def get_customer_id_for(user): try: customer = create_customer(user.username, user.email) uncloud_stripe_mapping = uncloud_pay.models.StripeCustomer.objects.create( - owner=user, - stripe_id=customer_request['response_object']['id'] - ) + owner=user, stripe_id=customer.id) return uncloud_stripe_mapping.stripe_id except Exception as e: return None From b88dfa4bfe63cd5ff35e8d34d72f3588df367d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 5 Mar 2020 11:28:43 +0100 Subject: [PATCH 233/284] Fix payment update updates --- uncloud/uncloud_pay/serializers.py | 5 +++++ uncloud/uncloud_pay/views.py | 2 ++ 2 files changed, 7 insertions(+) diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index bfbe0da..46ceab2 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -31,6 +31,11 @@ class PaymentMethodSerializer(serializers.ModelSerializer): model = PaymentMethod fields = ['uuid', 'source', 'description', 'primary', 'stripe_card_last4', 'active'] +class UpdatePaymentMethodSerializer(serializers.ModelSerializer): + class Meta: + model = PaymentMethod + fields = ['description', 'primary'] + class ChargePaymentMethodSerializer(serializers.Serializer): amount = serializers.DecimalField(max_digits=10, decimal_places=2) diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index 08e94a0..6b54214 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -41,6 +41,8 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): def get_serializer_class(self): if self.action == 'create': return CreatePaymentMethodSerializer + elif self.action == 'update': + return UpdatePaymentMethodSerializer elif self.action == 'charge': return ChargePaymentMethodSerializer else: From 546667d117b7e18780c7476bc82856b730389e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 5 Mar 2020 11:36:19 +0100 Subject: [PATCH 234/284] Remove unused empty migration --- .../migrations/0018_auto_20200305_0819.py | 13 ------------- .../migrations/0019_auto_20200305_0851.py | 2 +- 2 files changed, 1 insertion(+), 14 deletions(-) delete mode 100644 uncloud/uncloud_pay/migrations/0018_auto_20200305_0819.py diff --git a/uncloud/uncloud_pay/migrations/0018_auto_20200305_0819.py b/uncloud/uncloud_pay/migrations/0018_auto_20200305_0819.py deleted file mode 100644 index e0f9087..0000000 --- a/uncloud/uncloud_pay/migrations/0018_auto_20200305_0819.py +++ /dev/null @@ -1,13 +0,0 @@ -# Generated by Django 3.0.4 on 2020-03-05 08:19 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0017_auto_20200304_1723'), - ] - - operations = [ - ] diff --git a/uncloud/uncloud_pay/migrations/0019_auto_20200305_0851.py b/uncloud/uncloud_pay/migrations/0019_auto_20200305_0851.py index d8a7c22..f8b56cc 100644 --- a/uncloud/uncloud_pay/migrations/0019_auto_20200305_0851.py +++ b/uncloud/uncloud_pay/migrations/0019_auto_20200305_0851.py @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('uncloud_pay', '0018_auto_20200305_0819'), + ('uncloud_pay', '0017_auto_20200304_1723'), ] operations = [ From e9b6a6f27771e4195cc66f843e3e0d319bd99ed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 5 Mar 2020 11:43:07 +0100 Subject: [PATCH 235/284] Fix migration dependencies after rebase --- uncloud/uncloud_pay/migrations/0017_auto_20200304_1723.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud/uncloud_pay/migrations/0017_auto_20200304_1723.py b/uncloud/uncloud_pay/migrations/0017_auto_20200304_1723.py index 3321e66..48142e4 100644 --- a/uncloud/uncloud_pay/migrations/0017_auto_20200304_1723.py +++ b/uncloud/uncloud_pay/migrations/0017_auto_20200304_1723.py @@ -6,7 +6,7 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('uncloud_pay', '0016_auto_20200303_1552'), + ('uncloud_pay', '0001_initial'), ] operations = [ From b958cc77ead642c7cac2665a2d64e8771293bd8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 5 Mar 2020 11:45:37 +0100 Subject: [PATCH 236/284] Fix duplicates in payment method creation --- uncloud/uncloud_pay/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index 6b54214..32350ff 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -56,7 +56,6 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): def create(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - payment_method = PaymentMethod.objects.create(owner=request.user, **serializer.validated_data) if serializer.validated_data['source'] == "stripe": # Retrieve Stripe customer ID for user. @@ -83,8 +82,9 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): payment_method.uuid) stripe_registration_url = reverse('api-root', request=request) + path return Response({'please_visit': stripe_registration_url}) - - return Response(serializer.data) + else: + serializer.save(owner=request.user, **serializer.validated_data) + return Response(serializer.data) @action(detail=True, methods=['post']) def charge(self, request, pk=None): From b07df26eb26a1faa6d3098cdc5c8c89982682aab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 5 Mar 2020 11:51:08 +0100 Subject: [PATCH 237/284] Move STRIPE_PUBLIC_KEY to secrets (i.e. local configuration) --- uncloud/uncloud/secrets_sample.py | 3 ++- uncloud/uncloud/settings.py | 4 ---- uncloud/uncloud_pay/stripe.py | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/uncloud/uncloud/secrets_sample.py b/uncloud/uncloud/secrets_sample.py index 464662f..bc9cd38 100644 --- a/uncloud/uncloud/secrets_sample.py +++ b/uncloud/uncloud/secrets_sample.py @@ -15,6 +15,7 @@ LDAP_ADMIN_PASSWORD="" LDAP_SERVER_URI = "" # Stripe (Credit Card payments) -STRIPE_API_key="" +STRIPE_KEY="" +STRIPE_PUBLIC_KEY="" SECRET_KEY="dx$iqt=lc&yrp^!z5$ay^%g5lhx1y3bcu=jg(jx0yj0ogkfqvf" diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index 9f1ac91..cc0ec3a 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -172,7 +172,3 @@ USE_TZ = True # https://docs.djangoproject.com/en/3.0/howto/static-files/ STATIC_URL = '/static/' - -################################################################################ -# Stripe -STRIPE_PUBLIC_KEY="" diff --git a/uncloud/uncloud_pay/stripe.py b/uncloud/uncloud_pay/stripe.py index ab3eac2..f23002b 100644 --- a/uncloud/uncloud_pay/stripe.py +++ b/uncloud/uncloud_pay/stripe.py @@ -64,7 +64,7 @@ def handle_stripe_error(f): # Actual Stripe logic. def public_api_key(): - return uncloud.settings.STRIPE_PUBLIC_KEY + return uncloud.secrets.STRIPE_PUBLIC_KEY def get_customer_id_for(user): try: From 10f09c7115ed11ffd8828f9ef3e6228412603a05 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 5 Mar 2020 14:15:33 +0100 Subject: [PATCH 238/284] add an old client hack (just for reference) --- uncloud/client/__init__.py | 0 uncloud/client/main.py | 23 +++++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 uncloud/client/__init__.py create mode 100644 uncloud/client/main.py diff --git a/uncloud/client/__init__.py b/uncloud/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud/client/main.py b/uncloud/client/main.py new file mode 100644 index 0000000..062308c --- /dev/null +++ b/uncloud/client/main.py @@ -0,0 +1,23 @@ +import argparse +import etcd3 +from uncloud.common.etcd_wrapper import Etcd3Wrapper + +arg_parser = argparse.ArgumentParser('client', add_help=False) +arg_parser.add_argument('--dump-etcd-contents-prefix', help="Dump contents below the given prefix") + +def dump_etcd_contents(prefix): + etcd = Etcd3Wrapper() + for k,v in etcd.get_prefix_raw(prefix): + k = k.decode('utf-8') + v = v.decode('utf-8') + print("{} = {}".format(k,v)) +# print("{} = {}".format(k,v)) + +# for k,v in etcd.get_prefix(prefix): +# + print("done") + + +def main(arguments): + if 'dump_etcd_contents_prefix' in arguments: + dump_etcd_contents(prefix=arguments['dump_etcd_contents_prefix']) From 6c7f0e98b3b6721bdb54d81806c68af3b8abfbae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 5 Mar 2020 16:24:45 +0100 Subject: [PATCH 239/284] Rebuild paymentmethod/stripe migrations from master --- ...0305_0851.py => 0002_auto_20200305_1524.py} | 16 ++++++++++------ .../migrations/0017_auto_20200304_1723.py | 17 ----------------- .../migrations/0020_auto_20200305_0911.py | 18 ------------------ 3 files changed, 10 insertions(+), 41 deletions(-) rename uncloud/uncloud_pay/migrations/{0019_auto_20200305_0851.py => 0002_auto_20200305_1524.py} (53%) delete mode 100644 uncloud/uncloud_pay/migrations/0017_auto_20200304_1723.py delete mode 100644 uncloud/uncloud_pay/migrations/0020_auto_20200305_0911.py diff --git a/uncloud/uncloud_pay/migrations/0019_auto_20200305_0851.py b/uncloud/uncloud_pay/migrations/0002_auto_20200305_1524.py similarity index 53% rename from uncloud/uncloud_pay/migrations/0019_auto_20200305_0851.py rename to uncloud/uncloud_pay/migrations/0002_auto_20200305_1524.py index f8b56cc..0768dd0 100644 --- a/uncloud/uncloud_pay/migrations/0019_auto_20200305_0851.py +++ b/uncloud/uncloud_pay/migrations/0002_auto_20200305_1524.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.4 on 2020-03-05 08:51 +# Generated by Django 3.0.3 on 2020-03-05 15:24 from django.db import migrations, models @@ -6,18 +6,22 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('uncloud_pay', '0017_auto_20200304_1723'), + ('uncloud_pay', '0001_initial'), ] operations = [ + migrations.RenameField( + model_name='paymentmethod', + old_name='stripe_card_id', + new_name='stripe_payment_method_id', + ), migrations.AddField( model_name='paymentmethod', name='stripe_setup_intent_id', field=models.CharField(blank=True, max_length=32, null=True), ), - migrations.AlterField( - model_name='paymentmethod', - name='stripe_card_id', - field=models.CharField(blank=True, max_length=32, null=True), + migrations.AlterUniqueTogether( + name='paymentmethod', + unique_together=set(), ), ] diff --git a/uncloud/uncloud_pay/migrations/0017_auto_20200304_1723.py b/uncloud/uncloud_pay/migrations/0017_auto_20200304_1723.py deleted file mode 100644 index 48142e4..0000000 --- a/uncloud/uncloud_pay/migrations/0017_auto_20200304_1723.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 3.0.4 on 2020-03-04 17:23 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0001_initial'), - ] - - operations = [ - migrations.AlterUniqueTogether( - name='paymentmethod', - unique_together=set(), - ), - ] diff --git a/uncloud/uncloud_pay/migrations/0020_auto_20200305_0911.py b/uncloud/uncloud_pay/migrations/0020_auto_20200305_0911.py deleted file mode 100644 index 9e1b677..0000000 --- a/uncloud/uncloud_pay/migrations/0020_auto_20200305_0911.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.0.4 on 2020-03-05 09:11 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0019_auto_20200305_0851'), - ] - - operations = [ - migrations.RenameField( - model_name='paymentmethod', - old_name='stripe_card_id', - new_name='stripe_payment_method_id', - ), - ] From b15a12dc71a7da818c22d1fe95ba5e7f3a832aaf Mon Sep 17 00:00:00 2001 From: meow Date: Fri, 13 Mar 2020 14:22:49 +0500 Subject: [PATCH 240/284] Missing import for DCLVMProductSerializer --- uncloud/uncloud_vm/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index faac214..cac743c 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -11,7 +11,9 @@ from rest_framework.exceptions import ValidationError from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct from uncloud_pay.models import Order -from .serializers import VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer, VMDiskImageProductSerializer, VMDiskProductSerializer +from .serializers import (VMHostSerializer, VMProductSerializer, + VMSnapshotProductSerializer, VMDiskImageProductSerializer, + VMDiskProductSerializer, DCLVMProductSerializer) from uncloud_pay.helpers import ProductViewSet From 8f4e7cca1b705cb34d6e4b291854b6094155f192 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 12:46:02 +0100 Subject: [PATCH 241/284] add migrations to ungleich_service so tests don't fail Signed-off-by: Nico Schottelius --- .../migrations/0001_initial.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 uncloud/ungleich_service/migrations/0001_initial.py diff --git a/uncloud/ungleich_service/migrations/0001_initial.py b/uncloud/ungleich_service/migrations/0001_initial.py new file mode 100644 index 0000000..5b843c8 --- /dev/null +++ b/uncloud/ungleich_service/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 3.0.3 on 2020-03-17 11:45 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('uncloud_vm', '0003_remove_vmhost_vms'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_pay', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='MatrixServiceProduct', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted')], default='PENDING', max_length=32)), + ('domain', models.CharField(default='domain.tld', max_length=255)), + ('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('vm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMProduct')), + ], + options={ + 'abstract': False, + }, + ), + ] From 723d2a99ccd9cd5d44f1a26af0be84e5383312e6 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 13:30:48 +0100 Subject: [PATCH 242/284] =?UTF-8?q?add=20django=E2=80=A6extensions=20to=20?= =?UTF-8?q?support=20"graph=5Fmodels"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- uncloud/requirements.txt | 2 ++ uncloud/uncloud/settings.py | 1 + 2 files changed, 3 insertions(+) diff --git a/uncloud/requirements.txt b/uncloud/requirements.txt index 1b4e05b..c8a15d3 100644 --- a/uncloud/requirements.txt +++ b/uncloud/requirements.txt @@ -4,3 +4,5 @@ django-auth-ldap stripe xmltodict psycopg2 + +parsedatetime diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index cc0ec3a..99cf7a1 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -57,6 +57,7 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'django_extensions', 'rest_framework', 'uncloud_pay', 'uncloud_auth', From 8356404fe424aba1bb179a34be2f0124ac3a05b0 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 14:49:36 +0100 Subject: [PATCH 243/284] ++ product readme --- uncloud/README-how-to-create-a-product.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 uncloud/README-how-to-create-a-product.md diff --git a/uncloud/README-how-to-create-a-product.md b/uncloud/README-how-to-create-a-product.md new file mode 100644 index 0000000..6ddd1fa --- /dev/null +++ b/uncloud/README-how-to-create-a-product.md @@ -0,0 +1,9 @@ +## Introduction + +This document describes how to create a product and use it. + +A product (like a VMSnapshotproduct) creates an order when ordered. +The "order" is used to combine products together. + +Sub-products or related products link to the same order. +Each product has one (?) orderrecord From ac7ea86668b6dfcd4065ae38aba3ebfe83a8a539 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 14:49:49 +0100 Subject: [PATCH 244/284] rename opennebula commands --- .../commands/{synchost.py => opennebula-synchosts.py} | 10 +++++----- .../commands/{syncvm.py => opennebula-syncvms.py} | 0 ...e-one-vm-to-regular.py => opennebula-to-uncloud.py} | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename uncloud/opennebula/management/commands/{synchost.py => opennebula-synchosts.py} (90%) rename uncloud/opennebula/management/commands/{syncvm.py => opennebula-syncvms.py} (100%) rename uncloud/opennebula/management/commands/{migrate-one-vm-to-regular.py => opennebula-to-uncloud.py} (98%) diff --git a/uncloud/opennebula/management/commands/synchost.py b/uncloud/opennebula/management/commands/opennebula-synchosts.py similarity index 90% rename from uncloud/opennebula/management/commands/synchost.py rename to uncloud/opennebula/management/commands/opennebula-synchosts.py index 6e4ea0f..29f9ac1 100644 --- a/uncloud/opennebula/management/commands/synchost.py +++ b/uncloud/opennebula/management/commands/opennebula-synchosts.py @@ -57,17 +57,17 @@ class Command(BaseCommand): usable_ram_in_kb = int(host_share.get('TOTAL_MEM', 0)) usable_ram_in_gb = int(usable_ram_in_kb / 2 ** 20) - vms = host.get('VMS', {}) or {} - vms = vms.get('ID', []) or [] - vms = ','.join(vms) + # vms cannot be created like this -- Nico, 2020-03-17 + # vms = host.get('VMS', {}) or {} + # vms = vms.get('ID', []) or [] + # vms = ','.join(vms) VMHost.objects.update_or_create( hostname=host_name, defaults={ 'usable_cores': usable_cores, 'usable_ram_in_gb': usable_ram_in_gb, - 'status': status, - 'vms': vms + 'status': status } ) else: diff --git a/uncloud/opennebula/management/commands/syncvm.py b/uncloud/opennebula/management/commands/opennebula-syncvms.py similarity index 100% rename from uncloud/opennebula/management/commands/syncvm.py rename to uncloud/opennebula/management/commands/opennebula-syncvms.py diff --git a/uncloud/opennebula/management/commands/migrate-one-vm-to-regular.py b/uncloud/opennebula/management/commands/opennebula-to-uncloud.py similarity index 98% rename from uncloud/opennebula/management/commands/migrate-one-vm-to-regular.py rename to uncloud/opennebula/management/commands/opennebula-to-uncloud.py index 68cf1f2..2f91f83 100644 --- a/uncloud/opennebula/management/commands/migrate-one-vm-to-regular.py +++ b/uncloud/opennebula/management/commands/opennebula-to-uncloud.py @@ -83,7 +83,7 @@ class Command(BaseCommand): def handle(self, *args, **options): for one_vm in VMModel.objects.all(): # Host on which the VM is currently residing - host = VMHost.objects.filter(vms__icontains=one_vm.vmid).first() + #host = VMHost.objects.filter(vms__icontains=one_vm.vmid).first() # VCPU, RAM, Owner, Status # TODO: Set actual status instead of hard coded 'active' From 8634d667d5267a2565b37fd532742e6020767101 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 14:49:59 +0100 Subject: [PATCH 245/284] update requirements for graphing --- uncloud/requirements.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/uncloud/requirements.txt b/uncloud/requirements.txt index c8a15d3..c7ebc65 100644 --- a/uncloud/requirements.txt +++ b/uncloud/requirements.txt @@ -6,3 +6,8 @@ xmltodict psycopg2 parsedatetime + +# Follow are for creating graph models +pyparsing +pydot +django-extensions From 55bd42fe64707b43c8ec713c83fde5760b5e6a6b Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 14:50:14 +0100 Subject: [PATCH 246/284] List all VMs for admins --- uncloud/uncloud_vm/views.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index cac743c..e1bbd22 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -85,7 +85,12 @@ class VMProductViewSet(ProductViewSet): serializer_class = VMProductSerializer def get_queryset(self): - return VMProduct.objects.filter(owner=self.request.user) + if self.request.user.is_superuser: + obj = VMProduct.objects.all() + else: + obj = VMProduct.objects.filter(owner=self.request.user) + + return obj # Use a database transaction so that we do not get half-created structure # if something goes wrong. From 9f4b927c742ffe9389662ff471f7638ad3315784 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 14:50:28 +0100 Subject: [PATCH 247/284] Introduce mirations to ungleich_service to make tests work --- uncloud/ungleich_service/migrations/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 uncloud/ungleich_service/migrations/__init__.py diff --git a/uncloud/ungleich_service/migrations/__init__.py b/uncloud/ungleich_service/migrations/__init__.py new file mode 100644 index 0000000..e69de29 From 5d840de55c04920ea269992c0402090048642b11 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 15:39:24 +0100 Subject: [PATCH 248/284] [opennebula] refresh formula, cleanup vm import/migration to uncloud --- .../commands/opennebula-to-uncloud.py | 34 +++++++++---------- uncloud/opennebula/models.py | 6 ++-- uncloud/opennebula/serializers.py | 6 ---- uncloud/opennebula/views.py | 29 +++++----------- 4 files changed, 29 insertions(+), 46 deletions(-) diff --git a/uncloud/opennebula/management/commands/opennebula-to-uncloud.py b/uncloud/opennebula/management/commands/opennebula-to-uncloud.py index 2f91f83..7b4b864 100644 --- a/uncloud/opennebula/management/commands/opennebula-to-uncloud.py +++ b/uncloud/opennebula/management/commands/opennebula-to-uncloud.py @@ -21,9 +21,8 @@ def convert_mac_to_int(mac_address: str): return mac_address -def get_vm_price(core, ram, storage, n_of_ipv4, n_of_ipv6): - storage = storage / 10 # Division by 10 because our base storage unit is 10 GB - total = 3 * core + 4 * ram + 3.5 * storage + 8 * n_of_ipv4 + 0 * n_of_ipv6 +def get_vm_price(core, ram, ssd_size, hdd_size, n_of_ipv4, n_of_ipv6): + total = 3 * core + 4 * ram + (3.5 * ssd_size/10.) + (1.5 * hdd_size/100.) + 8 * n_of_ipv4 + 0 * n_of_ipv6 # TODO: Find some reason about the following magical subtraction. total -= 8 @@ -82,17 +81,18 @@ class Command(BaseCommand): def handle(self, *args, **options): for one_vm in VMModel.objects.all(): - # Host on which the VM is currently residing - #host = VMHost.objects.filter(vms__icontains=one_vm.vmid).first() - # VCPU, RAM, Owner, Status - # TODO: Set actual status instead of hard coded 'active' - vm_id, cores, ram_in_gb = one_vm.vmid, one_vm.cores, one_vm.ram_in_gb - owner, status = one_vm.owner, 'active' + vmhost = VMHost.objects.get(hostname=one_vm.last_host) + cores = one_vm.cores + ram_in_gb = one_vm.ram_in_gb + owner = one_vm.owner + status = 'active' # Total Amount of SSD Storage # TODO: What would happen if the attached storage is not SSD but HDD? - total_storage_in_gb = sum([disk['size_in_gb'] for disk in one_vm.disks]) + + ssd_size = sum([ disk['size_in_gb'] for disk in one.disks if disk['pool_name'] in ['ssd', 'one'] ]) + hdd_size = sum([ disk['size_in_gb'] for disk in one.disks if disk['pool_name'] in ['hdd'] ]) # List of IPv4 addresses and Global IPv6 addresses ipv4, ipv6 = one_vm.ips @@ -101,18 +101,18 @@ class Command(BaseCommand): # instead of pseudo one we are putting currently creation_date = starting_date = ending_date = datetime.now(tz=timezone.utc) - # Price calculation - - # TODO: Make the following non-hardcoded + # Price calculation based on datacenterlight.ch one_time_price = 0 recurring_period = 'per_month' + recurring_price = get_vm_price(cores, ram_in_gb, + ssd_size, hdd_size, + len(ipv4), len(ipv6)) - recurring_price = get_vm_price(cores, ram_in_gb, total_storage_in_gb, len(ipv4), len(ipv6)) try: - vm_product = VMProduct.objects.get(vmid=vm_id) + vm_product = VMProduct.objects.get(name=one_vm.uncloud_name) except VMProduct.DoesNotExist: order = Order.objects.create( - owner=one_vm.owner, + owner=owner, creation_date=creation_date, starting_date=starting_date, ending_date=ending_date, @@ -121,7 +121,7 @@ class Command(BaseCommand): recurring_period=recurring_period ) vm_product, _ = VMProduct.objects.update_or_create( - vmid=vm_id, + name= defaults={ 'cores': cores, 'ram_in_gb': ram_in_gb, diff --git a/uncloud/opennebula/models.py b/uncloud/opennebula/models.py index 0748ff5..f5faeb5 100644 --- a/uncloud/opennebula/models.py +++ b/uncloud/opennebula/models.py @@ -9,9 +9,9 @@ class VM(models.Model): owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) data = JSONField() - def save(self, *args, **kwargs): - self.id = 'opennebula' + str(self.data.get("ID")) - super().save(*args, **kwargs) + @property + def uncloud_name(self): + return "opennebula-{}".format(self.vmid) @property def cores(self): diff --git a/uncloud/opennebula/serializers.py b/uncloud/opennebula/serializers.py index 64fe005..8e0c513 100644 --- a/uncloud/opennebula/serializers.py +++ b/uncloud/opennebula/serializers.py @@ -2,12 +2,6 @@ from rest_framework import serializers from opennebula.models import VM -class VMSerializer(serializers.HyperlinkedModelSerializer): - class Meta: - model = VM - fields = '__all__' - - class OpenNebulaVMSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = VM diff --git a/uncloud/opennebula/views.py b/uncloud/opennebula/views.py index 61ed5a4..89b1a52 100644 --- a/uncloud/opennebula/views.py +++ b/uncloud/opennebula/views.py @@ -1,27 +1,16 @@ from rest_framework import viewsets, permissions -from rest_framework.response import Response -from django.shortcuts import get_object_or_404 from .models import VM -from .serializers import VMSerializer, OpenNebulaVMSerializer +from .serializers import OpenNebulaVMSerializer - -class RawVMViewSet(viewsets.ModelViewSet): - queryset = VM.objects.all() - serializer_class = VMSerializer - permission_classes = [permissions.IsAdminUser] - - -class VMViewSet(viewsets.ViewSet): +class VMViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] + serializer_class = OpenNebulaVMSerializer - def list(self, request): - queryset = VM.objects.filter(owner=request.user) - serializer = OpenNebulaVMSerializer(queryset, many=True, context={'request': request}) - return Response(serializer.data) + def get_queryset(self): + if self.request.user.is_superuser: + obj = VM.objects.all() + else: + obj = VM.objects.filter(owner=self.request.user) - def retrieve(self, request, pk=None): - queryset = VM.objects.filter(owner=request.user) - vm = get_object_or_404(queryset, pk=pk) - serializer = OpenNebulaVMSerializer(vm, context={'request': request}) - return Response(serializer.data) + return obj From cc2efa5c145e884f06ce42a222a3575a49b0f704 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 15:40:08 +0100 Subject: [PATCH 249/284] Remove old opennebula view, remove vmid field --- uncloud/uncloud/urls.py | 1 - .../migrations/0004_remove_vmproduct_vmid.py | 17 +++++++++++++++++ uncloud/uncloud_vm/models.py | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index d7ee153..29575e9 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -72,7 +72,6 @@ router.register(r'admin/payment', payviews.AdminPaymentViewSet, basename='admin/ router.register(r'admin/order', payviews.AdminOrderViewSet, basename='admin/order') router.register(r'admin/vmhost', vmviews.VMHostViewSet) router.register(r'admin/opennebula', oneviews.VMViewSet, basename='opennebula') -router.register(r'admin/opennebula_raw', oneviews.RawVMViewSet, basename='opennebula_raw') urlpatterns = [ diff --git a/uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py b/uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py new file mode 100644 index 0000000..5f44b57 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.3 on 2020-03-17 14:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0003_remove_vmhost_vms'), + ] + + operations = [ + migrations.RemoveField( + model_name='vmproduct', + name='vmid', + ), + ] diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 60dfc0a..2bb27e9 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -64,7 +64,7 @@ class VMProduct(Product): name = models.CharField(max_length=32, blank=True, null=True) cores = models.IntegerField() ram_in_gb = models.FloatField() - vmid = models.IntegerField(null=True) + def recurring_price(self, recurring_period=RecurringPeriod.PER_MONTH): # TODO: move magic numbers in variables From b9473c180306b79df3b1c1435f14bbacf15e92ee Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 16:03:41 +0100 Subject: [PATCH 250/284] ++ fix opennebula migration --- .../commands/opennebula-to-uncloud.py | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/uncloud/opennebula/management/commands/opennebula-to-uncloud.py b/uncloud/opennebula/management/commands/opennebula-to-uncloud.py index 7b4b864..dc7cb45 100644 --- a/uncloud/opennebula/management/commands/opennebula-to-uncloud.py +++ b/uncloud/opennebula/management/commands/opennebula-to-uncloud.py @@ -5,6 +5,7 @@ from django.utils import timezone from opennebula.models import VM as VMModel from uncloud_vm.models import VMHost, VMProduct, VMNetworkCard, VMDiskImageProduct, VMDiskProduct + from uncloud_pay.models import Order @@ -82,7 +83,16 @@ class Command(BaseCommand): def handle(self, *args, **options): for one_vm in VMModel.objects.all(): - vmhost = VMHost.objects.get(hostname=one_vm.last_host) + if not one_vm.last_host: + print("No VMHost for VM {} - VM might be on hold - skipping".format(one_vm.vmid)) + continue + + try: + vmhost = VMHost.objects.get(hostname=one_vm.last_host) + except VMHost.DoesNotExist: + print("VMHost {} does not exist, aborting".format(one_vm.last_host)) + raise + cores = one_vm.cores ram_in_gb = one_vm.ram_in_gb owner = one_vm.owner @@ -91,15 +101,15 @@ class Command(BaseCommand): # Total Amount of SSD Storage # TODO: What would happen if the attached storage is not SSD but HDD? - ssd_size = sum([ disk['size_in_gb'] for disk in one.disks if disk['pool_name'] in ['ssd', 'one'] ]) - hdd_size = sum([ disk['size_in_gb'] for disk in one.disks if disk['pool_name'] in ['hdd'] ]) + ssd_size = sum([ disk['size_in_gb'] for disk in one_vm.disks if disk['pool_name'] in ['ssd', 'one'] ]) + hdd_size = sum([ disk['size_in_gb'] for disk in one_vm.disks if disk['pool_name'] in ['hdd'] ]) # List of IPv4 addresses and Global IPv6 addresses ipv4, ipv6 = one_vm.ips # TODO: Insert actual/real creation_date, starting_date, ending_date # instead of pseudo one we are putting currently - creation_date = starting_date = ending_date = datetime.now(tz=timezone.utc) + creation_date = starting_date = datetime.now(tz=timezone.utc) # Price calculation based on datacenterlight.ch one_time_price = 0 @@ -114,19 +124,18 @@ class Command(BaseCommand): order = Order.objects.create( owner=owner, creation_date=creation_date, - starting_date=starting_date, - ending_date=ending_date, - one_time_price=one_time_price, - recurring_price=recurring_price, - recurring_period=recurring_period + starting_date=starting_date +# one_time_price=one_time_price, +# recurring_price=recurring_price, +# recurring_period=recurring_period ) vm_product, _ = VMProduct.objects.update_or_create( - name= + name=one_vm.uncloud_name, defaults={ 'cores': cores, 'ram_in_gb': ram_in_gb, 'owner': owner, - 'vmhost': host, + 'vmhost': vmhost, 'order': order, 'status': status } From 6a382fab23cdb26701faad33e4a0a83ae1ee43bf Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 19:07:00 +0100 Subject: [PATCH 251/284] [vmhost] add used_ram_in_gb --- uncloud/uncloud_vm/models.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 2bb27e9..70ffd80 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -45,6 +45,10 @@ class VMHost(models.Model): def vms(self): return VMProduct.objects.filter(vmhost=self) + @property + def used_ram_in_gb(self): + return sum([vm.ram_in_gb for vm in VMProduct.objects.filter(vmhost=self)]) + @property def available_ram_in_gb(self): return self.usable_ram_in_gb - sum([vm.ram_in_gb for vm in self.vms ]) From 2f1aee818113d41506e4817af4c8fd29048fca47 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 17 Mar 2020 19:53:14 +0100 Subject: [PATCH 252/284] Can create a VMSnapshot w/ order (bugs to be removed) --- uncloud/uncloud_pay/helpers.py | 6 +++--- uncloud/uncloud_vm/serializers.py | 2 +- uncloud/uncloud_vm/views.py | 22 ++++++++++++++-------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud/uncloud_pay/helpers.py index d02b916..f791564 100644 --- a/uncloud/uncloud_pay/helpers.py +++ b/uncloud/uncloud_pay/helpers.py @@ -16,9 +16,9 @@ def end_of_month(year, month): hour=23, minute=59, second=59, tzinfo=tz) class ProductViewSet(mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.ListModelMixin, - GenericViewSet): + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + GenericViewSet): """ A customer-facing viewset that provides default `create()`, `retrieve()` and `list()`. diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index c92f108..75bcabe 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -31,7 +31,7 @@ class VMDiskImageProductSerializer(serializers.ModelSerializer): model = VMDiskImageProduct fields = '__all__' -class VMProductSerializer(serializers.HyperlinkedModelSerializer): +class VMProductSerializer(serializers.ModelSerializer): # Custom field used at creation (= ordering) only. recurring_period = serializers.ChoiceField( choices=VMProduct.allowed_recurring_periods()) diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index e1bbd22..7b5fa4f 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -29,7 +29,13 @@ class VMDiskImageProductMineViewSet(viewsets.ModelViewSet): serializer_class = VMDiskImageProductSerializer def get_queryset(self): - return VMDiskImageProduct.objects.filter(owner=self.request.user) + if self.request.user.is_superuser: + obj = VMDiskImageProduct.objects.all() + else: + obj = VMDiskImageProduct.objects.filter(owner=self.request.user) + + return obj + def create(self, request): serializer = VMDiskImageProductSerializer(data=request.data, context={'request': request}) @@ -132,9 +138,10 @@ class VMSnapshotProductViewSet(viewsets.ModelViewSet): # This verifies that the VM belongs to the request user serializer.is_valid(raise_exception=True) - disks = VMDiskProduct.objects.filter(vm=serializer.validated_data['vm']) - ssds_size = sum([d.size_in_gb for d in disks if d.storage_class == 'ssd']) - hdds_size = sum([d.size_in_gb for d in disks if d.storage_class == 'hdd']) + vm = vm=serializer.validated_data['vm'] + disks = VMDiskProduct.objects.filter(vm=vm) + ssds_size = sum([d.size_in_gb for d in disks if d.image.storage_class == 'ssd']) + hdds_size = sum([d.size_in_gb for d in disks if d.image.storage_class == 'hdd']) recurring_price = serializer.pricing['per_gb_ssd'] * ssds_size + serializer.pricing['per_gb_hdd'] * hdds_size recurring_period = serializer.pricing['recurring_period'] @@ -142,12 +149,11 @@ class VMSnapshotProductViewSet(viewsets.ModelViewSet): # Create order now = datetime.datetime.now() order = Order(owner=request.user, - creation_date=now, - starting_date=now, - recurring_price=recurring_price, - one_time_price=0, recurring_period=recurring_period) order.save() + order.add_record(one_time_price=0, + recurring_price=recurring_price, + description="Snapshot of VM {} from {}".format(vm, now)) serializer.save(owner=request.user, order=order, From cd01f62fdef8d96405588569d0af62f900c5d9ff Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 18 Mar 2020 14:36:40 +0100 Subject: [PATCH 253/284] Move user view to uncloud_auth --- uncloud/uncloud/urls.py | 5 ++++- uncloud/uncloud_auth/serializers.py | 14 ++++++++++++++ uncloud/uncloud_auth/views.py | 16 ++++++++++++++++ uncloud/uncloud_pay/models.py | 4 +--- uncloud/uncloud_pay/serializers.py | 14 -------------- uncloud/uncloud_pay/views.py | 6 ------ 6 files changed, 35 insertions(+), 24 deletions(-) create mode 100644 uncloud/uncloud_auth/serializers.py create mode 100644 uncloud/uncloud_auth/views.py diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 29575e9..856e59c 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -22,6 +22,7 @@ from uncloud_vm import views as vmviews from uncloud_pay import views as payviews from ungleich_service import views as serviceviews from opennebula import views as oneviews +from uncloud_auth import views as authviews router = routers.DefaultRouter() @@ -56,7 +57,6 @@ router.register(r'service/matrix', serviceviews.MatrixServiceProductViewSet, bas # Pay -router.register(r'user', payviews.UserViewSet, basename='user') router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='payment-method') router.register(r'bill', payviews.BillViewSet, basename='bill') router.register(r'order', payviews.OrderViewSet, basename='order') @@ -73,6 +73,9 @@ router.register(r'admin/order', payviews.AdminOrderViewSet, basename='admin/orde router.register(r'admin/vmhost', vmviews.VMHostViewSet) router.register(r'admin/opennebula', oneviews.VMViewSet, basename='opennebula') +# User/Account +router.register(r'user', authviews.UserViewSet, basename='user') + urlpatterns = [ path('', include(router.urls)), diff --git a/uncloud/uncloud_auth/serializers.py b/uncloud/uncloud_auth/serializers.py new file mode 100644 index 0000000..cd05112 --- /dev/null +++ b/uncloud/uncloud_auth/serializers.py @@ -0,0 +1,14 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers +from uncloud_pay.models import get_balance_for_user + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = get_user_model() + fields = ['username', 'email', 'balance'] + + # Display current 'balance' + balance = serializers.SerializerMethodField('get_balance') + + def get_balance(self, user): + return get_balance_for_user(user) diff --git a/uncloud/uncloud_auth/views.py b/uncloud/uncloud_auth/views.py new file mode 100644 index 0000000..40b8408 --- /dev/null +++ b/uncloud/uncloud_auth/views.py @@ -0,0 +1,16 @@ +from rest_framework import viewsets, permissions, status +from .serializers import * + +class UserViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + if self.request.user.is_superuser: + obj = get_user_model().objects.all() + else: + # This is a bit stupid: we have a user, we create a queryset by + # matching on the username. + obj = get_user_model().objects.filter(username=self.request.user.username) + + return obj diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 17afbcb..63f351a 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -44,10 +44,8 @@ class ProductStatus(models.TextChoices): ACTIVE = 'ACTIVE', _('Active') DELETED = 'DELETED', _('Deleted') -### -# Users. -def get_balance_for(user): +def get_balance_for_user(user): bills = reduce( lambda acc, entry: acc + entry.total, Bill.objects.filter(owner=user), diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud/uncloud_pay/serializers.py index 60ddc75..a0a8635 100644 --- a/uncloud/uncloud_pay/serializers.py +++ b/uncloud/uncloud_pay/serializers.py @@ -2,20 +2,6 @@ from django.contrib.auth import get_user_model from rest_framework import serializers from .models import * -### -# Users. - -class UserSerializer(serializers.ModelSerializer): - class Meta: - model = get_user_model() - fields = ['username', 'email', 'balance'] - - # Display current 'balance' - balance = serializers.SerializerMethodField('get_balance') - - def get_balance(self, user): - return get_balance_for(user) - ### # Payments and Payment Methods. diff --git a/uncloud/uncloud_pay/views.py b/uncloud/uncloud_pay/views.py index 57c284d..e86a464 100644 --- a/uncloud/uncloud_pay/views.py +++ b/uncloud/uncloud_pay/views.py @@ -48,12 +48,6 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return Order.objects.filter(owner=self.request.user) -class UserViewSet(viewsets.ReadOnlyModelViewSet): - serializer_class = UserSerializer - permission_classes = [permissions.IsAuthenticated] - - def get_queryset(self): - return get_user_model().objects.all() class PaymentMethodViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] From c6a9bd4363a1b039ad2983cfb77f4b31e8bf0773 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 18 Mar 2020 14:53:26 +0100 Subject: [PATCH 254/284] Make balance a user attribute + decimalfield --- uncloud/uncloud/__init__.py | 4 +++ .../migrations/0002_auto_20200318_1343.py | 25 +++++++++++++++++++ .../migrations/0003_auto_20200318_1345.py | 23 +++++++++++++++++ uncloud/uncloud_auth/models.py | 20 ++++++++++++++- uncloud/uncloud_auth/serializers.py | 13 +++++----- uncloud/uncloud_auth/views.py | 3 ++- uncloud/uncloud_pay/models.py | 12 ++++----- 7 files changed, 85 insertions(+), 15 deletions(-) create mode 100644 uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py create mode 100644 uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py diff --git a/uncloud/uncloud/__init__.py b/uncloud/uncloud/__init__.py index e69de29..9e2545a 100644 --- a/uncloud/uncloud/__init__.py +++ b/uncloud/uncloud/__init__.py @@ -0,0 +1,4 @@ +# Define DecimalField properties, used to represent amounts of money. +# Used in pay and auth +AMOUNT_MAX_DIGITS=10 +AMOUNT_DECIMALS=2 diff --git a/uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py b/uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py new file mode 100644 index 0000000..ad2654f --- /dev/null +++ b/uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.3 on 2020-03-18 13:43 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_auth', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='amount', + field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AddField( + model_name='user', + name='maximum_credit', + field=models.FloatField(default=0), + preserve_default=False, + ), + ] diff --git a/uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py b/uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py new file mode 100644 index 0000000..31b1717 --- /dev/null +++ b/uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.3 on 2020-03-18 13:45 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_auth', '0002_auto_20200318_1343'), + ] + + operations = [ + migrations.RemoveField( + model_name='user', + name='amount', + ), + migrations.AlterField( + model_name='user', + name='maximum_credit', + field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/uncloud/uncloud_auth/models.py b/uncloud/uncloud_auth/models.py index 3d30525..aef1e20 100644 --- a/uncloud/uncloud_auth/models.py +++ b/uncloud/uncloud_auth/models.py @@ -1,5 +1,23 @@ from django.contrib.auth.models import AbstractUser +from django.db import models +from django.core.validators import MinValueValidator +from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS + +from uncloud_pay.models import get_balance_for_user class User(AbstractUser): - pass + """ + We use the standard user and add a maximum negative credit that is allowed + to be accumulated + """ + + maximum_credit = models.DecimalField( + default=0.0, + max_digits=AMOUNT_MAX_DIGITS, + decimal_places=AMOUNT_DECIMALS, + validators=[MinValueValidator(0)]) + + @property + def balance(self): + return get_balance_for_user(self) diff --git a/uncloud/uncloud_auth/serializers.py b/uncloud/uncloud_auth/serializers.py index cd05112..3627149 100644 --- a/uncloud/uncloud_auth/serializers.py +++ b/uncloud/uncloud_auth/serializers.py @@ -1,14 +1,13 @@ from django.contrib.auth import get_user_model from rest_framework import serializers -from uncloud_pay.models import get_balance_for_user + +from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS class UserSerializer(serializers.ModelSerializer): + class Meta: model = get_user_model() - fields = ['username', 'email', 'balance'] + fields = ['username', 'email', 'balance', 'maximum_credit' ] - # Display current 'balance' - balance = serializers.SerializerMethodField('get_balance') - - def get_balance(self, user): - return get_balance_for_user(user) + balance = serializers.DecimalField(max_digits=AMOUNT_MAX_DIGITS, + decimal_places=AMOUNT_DECIMALS) diff --git a/uncloud/uncloud_auth/views.py b/uncloud/uncloud_auth/views.py index 40b8408..2f78e1f 100644 --- a/uncloud/uncloud_auth/views.py +++ b/uncloud/uncloud_auth/views.py @@ -10,7 +10,8 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet): obj = get_user_model().objects.all() else: # This is a bit stupid: we have a user, we create a queryset by - # matching on the username. + # matching on the username. But I don't know a "nicer" way. + # Nico, 2020-03-18 obj = get_user_model().objects.filter(username=self.request.user.username) return obj diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 63f351a..a11c3c1 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -14,14 +14,14 @@ from math import ceil from datetime import timedelta from calendar import monthrange -import uncloud_pay.stripe -from uncloud_pay.helpers import beginning_of_month, end_of_month - from decimal import Decimal -# Define DecimalField properties, used to represent amounts of money. -AMOUNT_MAX_DIGITS=10 -AMOUNT_DECIMALS=2 +import uncloud_pay.stripe +from uncloud_pay.helpers import beginning_of_month, end_of_month +from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS + + + # Used to generate bill due dates. BILL_PAYMENT_DELAY=timedelta(days=10) From 4b4cbbf009a1146800ae0374993e0bec6a0165da Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 18 Mar 2020 15:19:06 +0100 Subject: [PATCH 255/284] Also list snapshots for a VM --- uncloud/uncloud_auth/models.py | 4 ++-- uncloud/uncloud_vm/models.py | 4 +++- uncloud/uncloud_vm/serializers.py | 16 ++++++++++------ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/uncloud/uncloud_auth/models.py b/uncloud/uncloud_auth/models.py index aef1e20..c3a0912 100644 --- a/uncloud/uncloud_auth/models.py +++ b/uncloud/uncloud_auth/models.py @@ -8,8 +8,8 @@ from uncloud_pay.models import get_balance_for_user class User(AbstractUser): """ - We use the standard user and add a maximum negative credit that is allowed - to be accumulated + We use the standard user and add a maximum credit that is allowed + to be accumulated. After that we need to have warnings, cancellation, etc. """ maximum_credit = models.DecimalField( diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 70ffd80..57b54cf 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -186,4 +186,6 @@ class VMSnapshotProduct(Product): gb_ssd = models.FloatField(editable=False) gb_hdd = models.FloatField(editable=False) - vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) + vm = models.ForeignKey(VMProduct, + related_name='snapshots', + on_delete=models.CASCADE) diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index 75bcabe..f759d01 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -32,16 +32,20 @@ class VMDiskImageProductSerializer(serializers.ModelSerializer): fields = '__all__' class VMProductSerializer(serializers.ModelSerializer): - # Custom field used at creation (= ordering) only. - recurring_period = serializers.ChoiceField( - choices=VMProduct.allowed_recurring_periods()) - class Meta: model = VMProduct - fields = ['uuid', 'order', 'owner', 'status', 'name', \ - 'cores', 'ram_in_gb', 'recurring_period'] + fields = ['uuid', 'order', 'owner', 'status', 'name', + 'cores', 'ram_in_gb', 'recurring_period', + 'snapshots' ] read_only_fields = ['uuid', 'order', 'owner', 'status'] + # Custom field used at creation (= ordering) only. + recurring_period = serializers.ChoiceField( + choices=VMProduct.allowed_recurring_periods()) + + snapshots = serializers.PrimaryKeyRelatedField(many=True, + read_only=True) + class DCLVMProductSerializer(serializers.HyperlinkedModelSerializer): """ From a32f7522b551deaeb3f7dbf0a5534762a49f9b51 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 18 Mar 2020 15:43:01 +0100 Subject: [PATCH 256/284] Relate VM to disks and snapshots --- uncloud/uncloud_vm/models.py | 4 +++- uncloud/uncloud_vm/serializers.py | 35 ++++++++++++++++++------------- uncloud/uncloud_vm/views.py | 7 ++++++- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 57b54cf..7e38ded 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -152,7 +152,9 @@ class VMDiskProduct(models.Model): on_delete=models.CASCADE, editable=False) - vm = models.ForeignKey(VMProduct, on_delete=models.CASCADE) + vm = models.ForeignKey(VMProduct, + related_name='disks', + on_delete=models.CASCADE) image = models.ForeignKey(VMDiskImageProduct, on_delete=models.CASCADE) size_in_gb = models.FloatField(blank=True) diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index f759d01..96454f7 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -31,20 +31,6 @@ class VMDiskImageProductSerializer(serializers.ModelSerializer): model = VMDiskImageProduct fields = '__all__' -class VMProductSerializer(serializers.ModelSerializer): - class Meta: - model = VMProduct - fields = ['uuid', 'order', 'owner', 'status', 'name', - 'cores', 'ram_in_gb', 'recurring_period', - 'snapshots' ] - read_only_fields = ['uuid', 'order', 'owner', 'status'] - - # Custom field used at creation (= ordering) only. - recurring_period = serializers.ChoiceField( - choices=VMProduct.allowed_recurring_periods()) - - snapshots = serializers.PrimaryKeyRelatedField(many=True, - read_only=True) class DCLVMProductSerializer(serializers.HyperlinkedModelSerializer): @@ -92,3 +78,24 @@ class VMSnapshotProductSerializer(serializers.ModelSerializer): pricing['per_gb_ssd'] = 0.012 pricing['per_gb_hdd'] = 0.0006 pricing['recurring_period'] = 'per_day' + +class VMProductSerializer(serializers.ModelSerializer): + class Meta: + model = VMProduct + fields = ['uuid', 'order', 'owner', 'status', 'name', + 'cores', 'ram_in_gb', 'recurring_period', + 'snapshots', 'disks' ] + read_only_fields = ['uuid', 'order', 'owner', 'status'] + + # Custom field used at creation (= ordering) only. + recurring_period = serializers.ChoiceField( + choices=VMProduct.allowed_recurring_periods()) + + # snapshots = serializers.PrimaryKeyRelatedField(many=True, + # read_only=True) + + snapshots = VMSnapshotProductSerializer(many=True, + read_only=True) + + disks = VMDiskProductSerializer(many=True, + read_only=True) diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 7b5fa4f..1ef4974 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -65,7 +65,12 @@ class VMDiskProductViewSet(viewsets.ModelViewSet): serializer_class = VMDiskProductSerializer def get_queryset(self): - return VMDiskProduct.objects.filter(owner=self.request.user) + if self.request.user.is_superuser: + obj = VMDiskProduct.objects.all() + else: + obj = VMDiskProduct.objects.filter(owner=self.request.user) + + return obj def create(self, request): serializer = VMDiskProductSerializer(data=request.data, context={'request': request}) From 10c5257f90cf587b50bc06502bfbc7edd045c8c8 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sat, 21 Mar 2020 11:59:04 +0100 Subject: [PATCH 257/284] Introduce "extra_data" jsonfield --- uncloud/uncloud/models.py | 22 ++++++++ uncloud/uncloud_pay/models.py | 6 +-- .../migrations/0005_auto_20200321_1058.py | 50 +++++++++++++++++++ uncloud/uncloud_vm/models.py | 9 ++-- .../0002_matrixserviceproduct_extra_data.py | 19 +++++++ 5 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 uncloud/uncloud/models.py create mode 100644 uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py create mode 100644 uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py diff --git a/uncloud/uncloud/models.py b/uncloud/uncloud/models.py new file mode 100644 index 0000000..7ca5dfa --- /dev/null +++ b/uncloud/uncloud/models.py @@ -0,0 +1,22 @@ +from django.db import models +from django.contrib.postgres.fields import JSONField + +class UncloudModel(models.Model): + """ + This class extends the standard model with an + extra_data field that can be used to include public, + but internal information. + + For instance if you migrate from an existing virtualisation + framework to uncloud. + + The extra_data attribute should be considered a hack and whenever + data is necessary for running uncloud, it should **not** be stored + in there. + + """ + + extra_data = JSONField(editable=False, blank=True, null=True) + + class Meta: + abstract = True diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index a11c3c1..532e130 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -19,8 +19,7 @@ from decimal import Decimal import uncloud_pay.stripe from uncloud_pay.helpers import beginning_of_month, end_of_month from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS - - +from uncloud.models import UncloudModel # Used to generate bill due dates. @@ -418,6 +417,7 @@ class OrderRecord(models.Model): description = models.TextField() + @property def recurring_period(self): return self.order.recurring_period @@ -436,7 +436,7 @@ class OrderRecord(models.Model): # Abstract (= no database representation) class used as parent for products # (e.g. uncloud_vm.models.VMProduct). -class Product(models.Model): +class Product(UncloudModel): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, diff --git a/uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py b/uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py new file mode 100644 index 0000000..3799e6a --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py @@ -0,0 +1,50 @@ +# Generated by Django 3.0.3 on 2020-03-21 10:58 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0004_remove_vmproduct_vmid'), + ] + + operations = [ + migrations.AddField( + model_name='vmdiskimageproduct', + name='extra_data', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name='vmdiskproduct', + name='extra_data', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name='vmhost', + name='extra_data', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name='vmproduct', + name='extra_data', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name='vmsnapshotproduct', + name='extra_data', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True), + ), + migrations.AlterField( + model_name='vmdiskproduct', + name='vm', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='disks', to='uncloud_vm.VMProduct'), + ), + migrations.AlterField( + model_name='vmsnapshotproduct', + name='vm', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='snapshots', to='uncloud_vm.VMProduct'), + ), + ] diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index 7e38ded..bdd3a43 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -3,10 +3,13 @@ import uuid from django.db import models from django.contrib.auth import get_user_model + # Uncomment if you override model's clean method # from django.core.exceptions import ValidationError from uncloud_pay.models import Product, RecurringPeriod +from uncloud.models import UncloudModel + import uncloud_pay.models as pay_models import uncloud_storage.models @@ -22,7 +25,7 @@ STATUS_CHOICES = ( STATUS_DEFAULT = 'pending' -class VMHost(models.Model): +class VMHost(UncloudModel): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # 253 is the maximum DNS name length @@ -99,7 +102,7 @@ class VMWithOSProduct(VMProduct): pass -class VMDiskImageProduct(models.Model): +class VMDiskImageProduct(UncloudModel): """ Images are used for cloning/linking. @@ -138,7 +141,7 @@ class VMDiskImageProduct(models.Model): -class VMDiskProduct(models.Model): +class VMDiskProduct(UncloudModel): """ The VMDiskProduct is attached to a VM. diff --git a/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py b/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py new file mode 100644 index 0000000..f755ddb --- /dev/null +++ b/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.3 on 2020-03-21 10:58 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ungleich_service', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='matrixserviceproduct', + name='extra_data', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True), + ), + ] From 08fe3e689ef6acc11e66f721fc26cf1cb601039e Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 22 Mar 2020 17:30:55 +0100 Subject: [PATCH 258/284] Add debug to opennebula, create VM disks from opennebula correctly --- .../commands/opennebula-to-uncloud.py | 113 ++++++++++++------ uncloud/opennebula/models.py | 9 +- uncloud/opennebula/serializers.py | 4 +- uncloud/uncloud/urls.py | 4 +- uncloud/uncloud_auth/serializers.py | 2 + uncloud/uncloud_vm/models.py | 2 +- uncloud/uncloud_vm/serializers.py | 5 +- uncloud/uncloud_vm/views.py | 4 +- 8 files changed, 100 insertions(+), 43 deletions(-) diff --git a/uncloud/opennebula/management/commands/opennebula-to-uncloud.py b/uncloud/opennebula/management/commands/opennebula-to-uncloud.py index dc7cb45..230159a 100644 --- a/uncloud/opennebula/management/commands/opennebula-to-uncloud.py +++ b/uncloud/opennebula/management/commands/opennebula-to-uncloud.py @@ -1,13 +1,18 @@ +import sys from datetime import datetime from django.core.management.base import BaseCommand from django.utils import timezone +from django.contrib.auth import get_user_model from opennebula.models import VM as VMModel from uncloud_vm.models import VMHost, VMProduct, VMNetworkCard, VMDiskImageProduct, VMDiskProduct from uncloud_pay.models import Order +import logging + +log = logging.getLogger(__name__) def convert_mac_to_int(mac_address: str): # Remove octet connecting characters @@ -41,24 +46,35 @@ def create_nics(one_vm, vm_product): ) -def create_disk_and_image(one_vm, vm_product): - for disk in one_vm.disks: - owner = one_vm.owner - name = disk.get('image') +def sync_disk_and_image(one_vm, vm_product, disk_owner): + """ + a) Check all opennebula disk if they are in the uncloud VM, if not add + b) Check all uncloud disks and remove them if they are not in the opennebula VM + """ - # TODO: Fix the following hard coded values - is_os_image, is_public, status = True, True, 'active' + vmdisknum = 0 + + one_disks_extra_data = [] + + for disk in one_vm.disks: + vmowner = one_vm.owner + name = disk.get('image') + vmdisknum += 1 + + log.info("Checking disk {} for VM {}".format(name, one_vm)) + + is_os_image, is_public, status = True, False, 'active' image_size_in_gb = disk.get('image_size_in_gb') disk_size_in_gb = disk.get('size_in_gb') - storage_class = disk.get('pool_name') + storage_class = disk.get('storage_class') image_source = disk.get('source') image_source_type = disk.get('source_type') image, _ = VMDiskImageProduct.objects.update_or_create( name=name, defaults={ - 'owner': owner, + 'owner': disk_owner, 'is_os_image': is_os_image, 'is_public': is_public, 'size_in_gb': image_size_in_gb, @@ -68,29 +84,59 @@ def create_disk_and_image(one_vm, vm_product): 'status': status } ) - VMDiskProduct.objects.update_or_create( - owner=owner, vm=vm_product, - defaults={ - 'image': image, - 'size_in_gb': disk_size_in_gb - } - ) + # identify vmdisk from opennebula - primary mapping key + extra_data = { + 'opennebula_vm': one_vm.vmid, + 'opennebula_size_in_gb': disk_size_in_gb, + 'opennebula_source': disk.get('opennebula_source'), + 'opennebula_disk_num': vmdisknum + } + # Save for comparing later + one_disks_extra_data.append(extra_data) + + try: + vm_disk = VMDiskProduct.objects.get(extra_data=extra_data) + except VMDiskProduct.DoesNotExist: + vm_disk = VMDiskProduct.objects.create( + owner=vmowner, + vm=vm_product, + image=image, + size_in_gb=disk_size_in_gb, + extra_data=extra_data + ) + + # Now remove all disks that are not in above extra_data list + for disk in VMDiskProduct.objects.filter(vm=vm_product): + extra_data = disk.extra_data + if not extra_data in one_disks_extra_data: + log.info("Removing disk {} from VM {}".format(disk, vm_product)) + disk.delete() + + disks = [ disk.extra_data for disk in VMDiskProduct.objects.filter(vm=vm_product) ] + log.info("VM {} has disks: {}".format(vm_product, disks)) class Command(BaseCommand): help = 'Migrate Opennebula VM to regular (uncloud) vm' + def add_arguments(self, parser): + parser.add_argument('--disk-owner', required=True, help="The user who owns the the opennebula disks") + def handle(self, *args, **options): + log.debug("{} {}".format(args, options)) + + disk_owner = get_user_model().objects.get(username=options['disk_owner']) + for one_vm in VMModel.objects.all(): if not one_vm.last_host: - print("No VMHost for VM {} - VM might be on hold - skipping".format(one_vm.vmid)) + log.warning("No VMHost for VM {} - VM might be on hold - skipping".format(one_vm.vmid)) continue try: vmhost = VMHost.objects.get(hostname=one_vm.last_host) except VMHost.DoesNotExist: - print("VMHost {} does not exist, aborting".format(one_vm.last_host)) + log.error("VMHost {} does not exist, aborting".format(one_vm.last_host)) raise cores = one_vm.cores @@ -98,9 +144,6 @@ class Command(BaseCommand): owner = one_vm.owner status = 'active' - # Total Amount of SSD Storage - # TODO: What would happen if the attached storage is not SSD but HDD? - ssd_size = sum([ disk['size_in_gb'] for disk in one_vm.disks if disk['pool_name'] in ['ssd', 'one'] ]) hdd_size = sum([ disk['size_in_gb'] for disk in one_vm.disks if disk['pool_name'] in ['hdd'] ]) @@ -119,30 +162,32 @@ class Command(BaseCommand): len(ipv4), len(ipv6)) try: - vm_product = VMProduct.objects.get(name=one_vm.uncloud_name) + vm_product = VMProduct.objects.get(extra_data__opennebula_id=one_vm.vmid) except VMProduct.DoesNotExist: order = Order.objects.create( owner=owner, creation_date=creation_date, starting_date=starting_date -# one_time_price=one_time_price, -# recurring_price=recurring_price, -# recurring_period=recurring_period ) - vm_product, _ = VMProduct.objects.update_or_create( + vm_product = VMProduct( + extra_data={ 'opennebula_id': one_vm.vmid }, name=one_vm.uncloud_name, - defaults={ - 'cores': cores, - 'ram_in_gb': ram_in_gb, - 'owner': owner, - 'vmhost': vmhost, - 'order': order, - 'status': status - } + order=order ) + # we don't use update_or_create, as filtering by json AND setting json + # at the same time does not work + + vm_product.vmhost = vmhost + vm_product.owner = owner + vm_product.cores = cores + vm_product.ram_in_gb = ram_in_gb + vm_product.status = status + + vm_product.save() + # Create VMNetworkCards create_nics(one_vm, vm_product) # Create VMDiskImageProduct and VMDiskProduct - create_disk_and_image(one_vm, vm_product) + sync_disk_and_image(one_vm, vm_product, disk_owner=disk_owner) diff --git a/uncloud/opennebula/models.py b/uncloud/opennebula/models.py index f5faeb5..826b615 100644 --- a/uncloud/opennebula/models.py +++ b/uncloud/opennebula/models.py @@ -3,6 +3,12 @@ from django.db import models from django.contrib.auth import get_user_model from django.contrib.postgres.fields import JSONField +# ungleich specific +storage_class_mapping = { + 'one': 'ssd', + 'ssd': 'ssd', + 'hdd': 'hdd' +} class VM(models.Model): vmid = models.IntegerField(primary_key=True) @@ -48,7 +54,8 @@ class VM(models.Model): 'pool_name': d['POOL_NAME'], 'image': d['IMAGE'], 'source': d['SOURCE'], - 'source_type': d['TM_MAD'] + 'source_type': d['TM_MAD'], + 'storage_class': storage_class_mapping[d['POOL_NAME']] } for d in disks diff --git a/uncloud/opennebula/serializers.py b/uncloud/opennebula/serializers.py index 8e0c513..cd00622 100644 --- a/uncloud/opennebula/serializers.py +++ b/uncloud/opennebula/serializers.py @@ -5,4 +5,6 @@ from opennebula.models import VM class OpenNebulaVMSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = VM - fields = '__all__' + fields = [ 'vmid', 'owner', 'data', + 'uncloud_name', 'cores', 'ram_in_gb', + 'disks', 'nics', 'ips' ] diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 856e59c..50d59c3 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -28,9 +28,9 @@ router = routers.DefaultRouter() # VM router.register(r'vm/snapshot', vmviews.VMSnapshotProductViewSet, basename='vmsnapshotproduct') +router.register(r'vm/diskimage', vmviews.VMDiskImageProductViewSet, basename='vmdiskimageproduct') router.register(r'vm/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct') -router.register(r'vm/image/mine', vmviews.VMDiskImageProductMineViewSet, basename='vmdiskimagemineproduct') -router.register(r'vm/image/public', vmviews.VMDiskImageProductPublicViewSet, basename='vmdiskimagepublicproduct') + # images the provider provides :-) # router.register(r'vm/image/official', vmviews.VMDiskImageProductPublicViewSet, basename='vmdiskimagepublicproduct') diff --git a/uncloud/uncloud_auth/serializers.py b/uncloud/uncloud_auth/serializers.py index 3627149..de369c3 100644 --- a/uncloud/uncloud_auth/serializers.py +++ b/uncloud/uncloud_auth/serializers.py @@ -9,5 +9,7 @@ class UserSerializer(serializers.ModelSerializer): model = get_user_model() fields = ['username', 'email', 'balance', 'maximum_credit' ] + + balance = serializers.DecimalField(max_digits=AMOUNT_MAX_DIGITS, decimal_places=AMOUNT_DECIMALS) diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index bdd3a43..a4b7f2a 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -119,7 +119,7 @@ class VMDiskImageProduct(UncloudModel): name = models.CharField(max_length=256) is_os_image = models.BooleanField(default=False) - is_public = models.BooleanField(default=False) + is_public = models.BooleanField(default=False, editable=False) # only allow admins to set this size_in_gb = models.FloatField(null=True, blank=True) import_url = models.URLField(null=True, blank=True) diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index 96454f7..6d26cbe 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -84,8 +84,9 @@ class VMProductSerializer(serializers.ModelSerializer): model = VMProduct fields = ['uuid', 'order', 'owner', 'status', 'name', 'cores', 'ram_in_gb', 'recurring_period', - 'snapshots', 'disks' ] - read_only_fields = ['uuid', 'order', 'owner', 'status'] + 'snapshots', 'disks', + 'extra_data' ] + read_only_fields = ['uuid', 'order', 'owner', 'status' ] # Custom field used at creation (= ordering) only. recurring_period = serializers.ChoiceField( diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 1ef4974..6d4e5a9 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -24,7 +24,7 @@ class VMHostViewSet(viewsets.ModelViewSet): queryset = VMHost.objects.all() permission_classes = [permissions.IsAdminUser] -class VMDiskImageProductMineViewSet(viewsets.ModelViewSet): +class VMDiskImageProductViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] serializer_class = VMDiskImageProductSerializer @@ -32,7 +32,7 @@ class VMDiskImageProductMineViewSet(viewsets.ModelViewSet): if self.request.user.is_superuser: obj = VMDiskImageProduct.objects.all() else: - obj = VMDiskImageProduct.objects.filter(owner=self.request.user) + obj = VMDiskImageProduct.objects.filter(owner=self.request.user) | VMDiskImageProduct.objects.filter(is_public=True) return obj From 105142f76aa901d7bf5229de66d252318fc3058a Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 22 Mar 2020 18:52:31 +0100 Subject: [PATCH 259/284] Add template for creating VMs --- .../commands/vm-create-snapshots.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 uncloud/uncloud_vm/management/commands/vm-create-snapshots.py diff --git a/uncloud/uncloud_vm/management/commands/vm-create-snapshots.py b/uncloud/uncloud_vm/management/commands/vm-create-snapshots.py new file mode 100644 index 0000000..bd3bb65 --- /dev/null +++ b/uncloud/uncloud_vm/management/commands/vm-create-snapshots.py @@ -0,0 +1,35 @@ +import json + +import uncloud.secrets as secrets + +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model + +from uncloud_vm.models import VMSnapshotProduct +from datetime import datetime + +class Command(BaseCommand): + help = 'Select VM Host for VMs' + + def add_arguments(self, parser): + parser.add_argument('--this-hostname', required=True) + # parser.add_argument('--start-vms-here', action='store_true') + # parser.add_argument('--check-health', action='store_true') + # parser.add_argument('--vmhostname') + # print(parser) + + + def handle(self, *args, **options): + for snapshot in VMSnapshotProduct.objects.filter(status='PENDING'): + if not snapshot.extra_data: + snapshot.extra_data = {} + + # TODO: implement locking here + if 'creating_hostname' in snapshot.extra_data: + pass + + snapshot.extra_data['creating_hostname'] = options['this_hostname'] + snapshot.extra_data['creating_start'] = str(datetime.now()) + snapshot.save() + + print(snapshot) From 9961ca0446bea82f17d934f1f3f69d309bf7de3c Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 22 Mar 2020 18:59:59 +0100 Subject: [PATCH 260/284] add new migrations Signed-off-by: Nico Schottelius --- .../migrations/0006_auto_20200322_1758.py | 57 +++++++++++++++++++ .../migrations/0003_auto_20200322_1758.py | 18 ++++++ 2 files changed, 75 insertions(+) create mode 100644 uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py create mode 100644 uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py diff --git a/uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py b/uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py new file mode 100644 index 0000000..7726c9b --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py @@ -0,0 +1,57 @@ +# Generated by Django 3.0.3 on 2020-03-22 17:58 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0005_auto_20200321_1058'), + ] + + operations = [ + migrations.CreateModel( + name='VMCluster', + fields=[ + ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=128, unique=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AlterField( + model_name='vmdiskimageproduct', + name='is_public', + field=models.BooleanField(default=False, editable=False), + ), + migrations.AlterField( + model_name='vmdiskimageproduct', + name='status', + field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32), + ), + migrations.AlterField( + model_name='vmhost', + name='status', + field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32), + ), + migrations.AlterField( + model_name='vmproduct', + name='status', + field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32), + ), + migrations.AlterField( + model_name='vmsnapshotproduct', + name='status', + field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32), + ), + migrations.AddField( + model_name='vmproduct', + name='vmcluster', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMCluster'), + ), + ] diff --git a/uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py b/uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py new file mode 100644 index 0000000..73dbd6a --- /dev/null +++ b/uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-03-22 17:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ungleich_service', '0002_matrixserviceproduct_extra_data'), + ] + + operations = [ + migrations.AlterField( + model_name='matrixserviceproduct', + name='status', + field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('ACTIVE', 'Active'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32), + ), + ] From 23203ff418051669351692067883eddcbc6e268c Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Sun, 22 Mar 2020 20:55:11 +0100 Subject: [PATCH 261/284] vmsnapshot progress --- .../uncloud/management/commands/uncloud.py | 28 +++++ uncloud/uncloud/models.py | 13 ++ uncloud/uncloud/settings.py | 1 + uncloud/uncloud/urls.py | 19 +-- uncloud/uncloud_pay/models.py | 13 +- .../commands/vm-create-snapshots.py | 35 ------ uncloud/uncloud_vm/management/commands/vm.py | 114 ++++++++++++------ .../migrations/0007_vmhost_vmcluster.py | 19 +++ uncloud/uncloud_vm/models.py | 29 ++--- uncloud/uncloud_vm/serializers.py | 12 +- uncloud/uncloud_vm/views.py | 17 ++- 11 files changed, 175 insertions(+), 125 deletions(-) create mode 100644 uncloud/uncloud/management/commands/uncloud.py delete mode 100644 uncloud/uncloud_vm/management/commands/vm-create-snapshots.py create mode 100644 uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py diff --git a/uncloud/uncloud/management/commands/uncloud.py b/uncloud/uncloud/management/commands/uncloud.py new file mode 100644 index 0000000..bd47c6b --- /dev/null +++ b/uncloud/uncloud/management/commands/uncloud.py @@ -0,0 +1,28 @@ +import sys +from datetime import datetime + +from django.core.management.base import BaseCommand + +from django.contrib.auth import get_user_model + +from opennebula.models import VM as VMModel +from uncloud_vm.models import VMHost, VMProduct, VMNetworkCard, VMDiskImageProduct, VMDiskProduct, VMCluster + +import logging +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'General uncloud commands' + + def add_arguments(self, parser): + parser.add_argument('--bootstrap', action='store_true', help='Bootstrap a typical uncloud installation') + + def handle(self, *args, **options): + + if options['bootstrap']: + self.bootstrap() + + def bootstrap(self): + default_cluster = VMCluster.objects.get_or_create(name="default") +# local_host = diff --git a/uncloud/uncloud/models.py b/uncloud/uncloud/models.py index 7ca5dfa..bd7a931 100644 --- a/uncloud/uncloud/models.py +++ b/uncloud/uncloud/models.py @@ -1,5 +1,6 @@ from django.db import models from django.contrib.postgres.fields import JSONField +from django.utils.translation import gettext_lazy as _ class UncloudModel(models.Model): """ @@ -20,3 +21,15 @@ class UncloudModel(models.Model): class Meta: abstract = True + +# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types +class UncloudStatus(models.TextChoices): + PENDING = 'PENDING', _('Pending') + AWAITING_PAYMENT = 'AWAITING_PAYMENT', _('Awaiting payment') + BEING_CREATED = 'BEING_CREATED', _('Being created') + SCHEDULED = 'SCHEDULED', _('Scheduled') # resource selected, waiting for dispatching + ACTIVE = 'ACTIVE', _('Active') + MODIFYING = 'MODIFYING', _('Modifying') # Resource is being changed + DELETED = 'DELETED', _('Deleted') # Resource has been deleted + DISABLED = 'DISABLED', _('Disabled') # Is usable, but cannot be used for new things + UNUSABLE = 'UNUSABLE', _('Unusable'), # Has some kind of error diff --git a/uncloud/uncloud/settings.py b/uncloud/uncloud/settings.py index 99cf7a1..5b4744d 100644 --- a/uncloud/uncloud/settings.py +++ b/uncloud/uncloud/settings.py @@ -59,6 +59,7 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'django_extensions', 'rest_framework', + 'uncloud', 'uncloud_pay', 'uncloud_auth', 'uncloud_storage', diff --git a/uncloud/uncloud/urls.py b/uncloud/uncloud/urls.py index 50d59c3..a848dff 100644 --- a/uncloud/uncloud/urls.py +++ b/uncloud/uncloud/urls.py @@ -30,32 +30,16 @@ router = routers.DefaultRouter() router.register(r'vm/snapshot', vmviews.VMSnapshotProductViewSet, basename='vmsnapshotproduct') router.register(r'vm/diskimage', vmviews.VMDiskImageProductViewSet, basename='vmdiskimageproduct') router.register(r'vm/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct') - - -# images the provider provides :-) -# router.register(r'vm/image/official', vmviews.VMDiskImageProductPublicViewSet, basename='vmdiskimagepublicproduct') - - - - router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') - -# TBD -#router.register(r'vm/disk', vmviews.VMDiskProductViewSet, basename='vmdiskproduct') - # creates VM from os image #router.register(r'vm/ipv6onlyvm', vmviews.VMProductViewSet, basename='vmproduct') # ... AND adds IPv4 mapping #router.register(r'vm/dualstackvm', vmviews.VMProductViewSet, basename='vmproduct') -# allow vm creation from own images - - # Services router.register(r'service/matrix', serviceviews.MatrixServiceProductViewSet, basename='matrixserviceproduct') - # Pay router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='payment-method') router.register(r'bill', payviews.BillViewSet, basename='bill') @@ -63,14 +47,13 @@ router.register(r'order', payviews.OrderViewSet, basename='order') router.register(r'payment', payviews.PaymentViewSet, basename='payment') router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='payment-methods') -# VMs -router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vm') # admin/staff urls router.register(r'admin/bill', payviews.AdminBillViewSet, basename='admin/bill') router.register(r'admin/payment', payviews.AdminPaymentViewSet, basename='admin/payment') router.register(r'admin/order', payviews.AdminOrderViewSet, basename='admin/order') router.register(r'admin/vmhost', vmviews.VMHostViewSet) +router.register(r'admin/vmcluster', vmviews.VMClusterViewSet) router.register(r'admin/opennebula', oneviews.VMViewSet, basename='opennebula') # User/Account diff --git a/uncloud/uncloud_pay/models.py b/uncloud/uncloud_pay/models.py index 532e130..945187b 100644 --- a/uncloud/uncloud_pay/models.py +++ b/uncloud/uncloud_pay/models.py @@ -19,7 +19,7 @@ from decimal import Decimal import uncloud_pay.stripe from uncloud_pay.helpers import beginning_of_month, end_of_month from uncloud import AMOUNT_DECIMALS, AMOUNT_MAX_DIGITS -from uncloud.models import UncloudModel +from uncloud.models import UncloudModel, UncloudStatus # Used to generate bill due dates. @@ -35,13 +35,6 @@ class RecurringPeriod(models.TextChoices): PER_HOUR = 'HOUR', _('Per Hour') PER_SECOND = 'SECOND', _('Per Second') -# See https://docs.djangoproject.com/en/dev/ref/models/fields/#field-choices-enum-types -class ProductStatus(models.TextChoices): - PENDING = 'PENDING', _('Pending') - AWAITING_PAYMENT = 'AWAITING_PAYMENT', _('Awaiting payment') - BEING_CREATED = 'BEING_CREATED', _('Being created') - ACTIVE = 'ACTIVE', _('Active') - DELETED = 'DELETED', _('Deleted') def get_balance_for_user(user): @@ -445,8 +438,8 @@ class Product(UncloudModel): description = "" status = models.CharField(max_length=32, - choices=ProductStatus.choices, - default=ProductStatus.PENDING) + choices=UncloudStatus.choices, + default=UncloudStatus.PENDING) order = models.ForeignKey(Order, on_delete=models.CASCADE, diff --git a/uncloud/uncloud_vm/management/commands/vm-create-snapshots.py b/uncloud/uncloud_vm/management/commands/vm-create-snapshots.py deleted file mode 100644 index bd3bb65..0000000 --- a/uncloud/uncloud_vm/management/commands/vm-create-snapshots.py +++ /dev/null @@ -1,35 +0,0 @@ -import json - -import uncloud.secrets as secrets - -from django.core.management.base import BaseCommand -from django.contrib.auth import get_user_model - -from uncloud_vm.models import VMSnapshotProduct -from datetime import datetime - -class Command(BaseCommand): - help = 'Select VM Host for VMs' - - def add_arguments(self, parser): - parser.add_argument('--this-hostname', required=True) - # parser.add_argument('--start-vms-here', action='store_true') - # parser.add_argument('--check-health', action='store_true') - # parser.add_argument('--vmhostname') - # print(parser) - - - def handle(self, *args, **options): - for snapshot in VMSnapshotProduct.objects.filter(status='PENDING'): - if not snapshot.extra_data: - snapshot.extra_data = {} - - # TODO: implement locking here - if 'creating_hostname' in snapshot.extra_data: - pass - - snapshot.extra_data['creating_hostname'] = options['this_hostname'] - snapshot.extra_data['creating_start'] = str(datetime.now()) - snapshot.save() - - print(snapshot) diff --git a/uncloud/uncloud_vm/management/commands/vm.py b/uncloud/uncloud_vm/management/commands/vm.py index c0e2783..667c5ad 100644 --- a/uncloud/uncloud_vm/management/commands/vm.py +++ b/uncloud/uncloud_vm/management/commands/vm.py @@ -5,73 +5,108 @@ import uncloud.secrets as secrets from django.core.management.base import BaseCommand from django.contrib.auth import get_user_model -from uncloud_vm.models import VMProduct, VMHost +from uncloud_vm.models import VMSnapshotProduct, VMProduct, VMHost +from datetime import datetime class Command(BaseCommand): help = 'Select VM Host for VMs' def add_arguments(self, parser): + parser.add_argument('--this-hostname', required=True) + parser.add_argument('--this-cluster', required=True) + + parser.add_argument('--create-vm-snapshots', action='store_true') parser.add_argument('--schedule-vms', action='store_true') - parser.add_argument('--start-vms-here', action='store_true') - parser.add_argument('--check-health', action='store_true') - parser.add_argument('--vmhostname') - print(parser) + parser.add_argument('--start-vms', action='store_true') def handle(self, *args, **options): - print(args) - print(options) + for cmd in [ 'create_vm_snapshots', 'schedule_vms', 'start_vms' ]: + if options[cmd]: + f = getattr(self, cmd) + f(args, options) - if options['schedule_vms']: - self.schedule_vms(args, option) - if options['start_vms_here']: - if not options['vmhostname']: - raise Exception("Argument vmhostname is required to know which vmhost we are on") - self.start_vms(args, options) - if options['check_health']: - self.check_health(args, option) + def schedule_vms(self, *args, **options): + for pending_vm in VMProduct.objects.filter(status='PENDING'): + cores_needed = pending_vm.cores + ram_needed = pending_vm.ram_in_gb + + # Database filtering + possible_vmhosts = VMHost.objects.filter(physical_cores__gte=cores_needed) + + # Logical filtering + possible_vmhosts = [ vmhost for vmhost in possible_vmhosts + if vmhost.available_cores >=cores_needed + and vmhost.available_ram_in_gb >= ram_needed ] + + if not possible_vmhosts: + log.error("No suitable Host found - cannot schedule VM {}".format(pending_vm)) + continue + + vmhost = possible_vmhosts[0] + pending_vm.vmhost = vmhost + pending_vm.status = 'SCHEDULED' + pending_vm.save() + + print("Scheduled VM {} on VMHOST {}".format(pending_vm, pending_vm.vmhost)) + + print(self) def start_vms(self, *args, **options): - vmhost = VMHost.objects.get(status='active', - hostname=options['vmhostname']) + vmhost = VMHost.objects.get(hostname=options['this_hostname']) if not vmhost: - print("No active vmhost {} exists".format(options['vmhostname'])) + raise Exception("No vmhost {} exists".format(options['vmhostname'])) + + # not active? done here + if not vmhost.status = 'ACTIVE': return vms_to_start = VMProduct.objects.filter(vmhost=vmhost, - status='creating') + status='SCHEDULED') for vm in vms_to_start: - """ run qemu: check if VM is not already active / qemu running prepare / create the Qemu arguments - - """ + print("Starting VM {}".format(VM)) - def schedule_vms(self, *args, **options)): - pending_vms = VMProduct.objects.filter(vmhost__isnull=True) - vmhosts = VMHost.objects.filter(status='active') + def check_vms(self, *args, **options): + """ + Check if all VMs that are supposed to run are running + """ - for vm in pending_vms: - print(vm) + def modify_vms(self, *args, **options): + """ + Check all VMs that are requested to be modified and restart them + """ - found_vmhost = False - for vmhost in vmhosts: - if vmhost.available_cores >= vm.cores and vmhost.available_ram_in_gb >= vm.ram_in_gb: - vm.vmhost = vmhost - vm.status = "creating" - vm.save() - found_vmhost = True - print("Scheduled VM {} on VMHOST {}".format(vm, vmhost)) - break + def create_vm_snapshots(self, *args, **options): + this_cluster = VMCluster(option['this_cluster']) - if not found_vmhost: - print("Error: cannot schedule VM {}, no suitable host found".format(vm)) + for snapshot in VMSnapshotProduct.objects.filter(status='PENDING', + cluster=this_cluster): + if not snapshot.extra_data: + snapshot.extra_data = {} + + # TODO: implement locking here + if 'creating_hostname' in snapshot.extra_data: + pass + + snapshot.extra_data['creating_hostname'] = options['this_hostname'] + snapshot.extra_data['creating_start'] = str(datetime.now()) + snapshot.save() + + # something on the line of: + # for disk im vm.disks: + # rbd snap create pool/image-name@snapshot name + # snapshot.extra_data['snapshots'] + # register the snapshot names in extra_data (?) + + print(snapshot) def check_health(self, *args, **options): - pending_vms = VMProduct.objects.filter(vmhost__isnull=True) + pending_vms = VMProduct.objects.filter(status='PENDING') vmhosts = VMHost.objects.filter(status='active') # 1. Check that all active hosts reported back N seconds ago @@ -81,5 +116,4 @@ class Command(BaseCommand): # If VM snapshots exist without a VM -> notify user (?) - print("Nothing is good, you should implement me") diff --git a/uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py b/uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py new file mode 100644 index 0000000..6766dd7 --- /dev/null +++ b/uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.3 on 2020-03-22 18:09 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0006_auto_20200322_1758'), + ] + + operations = [ + migrations.AddField( + model_name='vmhost', + name='vmcluster', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_vm.VMCluster'), + ), + ] diff --git a/uncloud/uncloud_vm/models.py b/uncloud/uncloud_vm/models.py index a4b7f2a..3b2c46b 100644 --- a/uncloud/uncloud_vm/models.py +++ b/uncloud/uncloud_vm/models.py @@ -8,21 +8,14 @@ from django.contrib.auth import get_user_model # from django.core.exceptions import ValidationError from uncloud_pay.models import Product, RecurringPeriod -from uncloud.models import UncloudModel +from uncloud.models import UncloudModel, UncloudStatus import uncloud_pay.models as pay_models import uncloud_storage.models -STATUS_CHOICES = ( - ('pending', 'Pending'), # Initial state - ('creating', 'Creating'), # Creating VM/image/etc. - ('active', 'Active'), # Is usable / active - ('disabled', 'Disabled'), # Is usable, but cannot be used for new things - ('unusable', 'Unusable'), # Has some kind of error - ('deleted', 'Deleted'), # Does not exist anymore, only DB entry as a log -) - -STATUS_DEFAULT = 'pending' +class VMCluster(UncloudModel): + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=128, unique=True) class VMHost(UncloudModel): @@ -31,6 +24,10 @@ class VMHost(UncloudModel): # 253 is the maximum DNS name length hostname = models.CharField(max_length=253, unique=True) + vmcluster = models.ForeignKey( + VMCluster, on_delete=models.CASCADE, editable=False, blank=True, null=True + ) + # indirectly gives a maximum number of cores / VM - f.i. 32 physical_cores = models.IntegerField(default=0) @@ -41,7 +38,7 @@ class VMHost(UncloudModel): usable_ram_in_gb = models.FloatField(default=0) status = models.CharField( - max_length=32, choices=STATUS_CHOICES, default=STATUS_DEFAULT + max_length=32, choices=UncloudStatus.choices, default=UncloudStatus.PENDING ) @property @@ -54,7 +51,7 @@ class VMHost(UncloudModel): @property def available_ram_in_gb(self): - return self.usable_ram_in_gb - sum([vm.ram_in_gb for vm in self.vms ]) + return self.usable_ram_in_gb - self.used_ram_in_gb @property def available_cores(self): @@ -66,6 +63,10 @@ class VMProduct(Product): VMHost, on_delete=models.CASCADE, editable=False, blank=True, null=True ) + vmcluster = models.ForeignKey( + VMCluster, on_delete=models.CASCADE, editable=False, blank=True, null=True + ) + # VM-specific. The name is only intended for customers: it's a pain to # remember IDs (speaking from experience as ungleich customer)! name = models.CharField(max_length=32, blank=True, null=True) @@ -131,7 +132,7 @@ class VMDiskImageProduct(UncloudModel): default = uncloud_storage.models.StorageClass.SSD) status = models.CharField( - max_length=32, choices=STATUS_CHOICES, default=STATUS_DEFAULT + max_length=32, choices=UncloudStatus.choices, default=UncloudStatus.PENDING ) def __str__(self): diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud/uncloud_vm/serializers.py index 6d26cbe..c0cca48 100644 --- a/uncloud/uncloud_vm/serializers.py +++ b/uncloud/uncloud_vm/serializers.py @@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model from rest_framework import serializers -from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct +from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct, VMCluster from uncloud_pay.models import RecurringPeriod GB_SSD_PER_DAY=0.012 @@ -12,7 +12,7 @@ GB_SSD_PER_DAY=0.012 GB_HDD_PER_DAY=0.0006 -class VMHostSerializer(serializers.ModelSerializer): +class VMHostSerializer(serializers.HyperlinkedModelSerializer): vms = serializers.PrimaryKeyRelatedField(many=True, read_only=True) class Meta: @@ -20,6 +20,11 @@ class VMHostSerializer(serializers.ModelSerializer): fields = '__all__' read_only_fields = [ 'vms' ] +class VMClusterSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = VMCluster + fields = '__all__' + class VMDiskProductSerializer(serializers.ModelSerializer): class Meta: @@ -92,9 +97,6 @@ class VMProductSerializer(serializers.ModelSerializer): recurring_period = serializers.ChoiceField( choices=VMProduct.allowed_recurring_periods()) - # snapshots = serializers.PrimaryKeyRelatedField(many=True, - # read_only=True) - snapshots = VMSnapshotProductSerializer(many=True, read_only=True) diff --git a/uncloud/uncloud_vm/views.py b/uncloud/uncloud_vm/views.py index 6d4e5a9..0672904 100644 --- a/uncloud/uncloud_vm/views.py +++ b/uncloud/uncloud_vm/views.py @@ -8,12 +8,13 @@ from rest_framework import viewsets, permissions from rest_framework.response import Response from rest_framework.exceptions import ValidationError -from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct +from .models import VMHost, VMProduct, VMSnapshotProduct, VMDiskProduct, VMDiskImageProduct, VMCluster from uncloud_pay.models import Order from .serializers import (VMHostSerializer, VMProductSerializer, VMSnapshotProductSerializer, VMDiskImageProductSerializer, - VMDiskProductSerializer, DCLVMProductSerializer) + VMDiskProductSerializer, DCLVMProductSerializer, + VMClusterSerializer) from uncloud_pay.helpers import ProductViewSet @@ -24,6 +25,11 @@ class VMHostViewSet(viewsets.ModelViewSet): queryset = VMHost.objects.all() permission_classes = [permissions.IsAdminUser] +class VMClusterViewSet(viewsets.ModelViewSet): + serializer_class = VMClusterSerializer + queryset = VMCluster.objects.all() + permission_classes = [permissions.IsAdminUser] + class VMDiskImageProductViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticated] serializer_class = VMDiskImageProductSerializer @@ -135,7 +141,12 @@ class VMSnapshotProductViewSet(viewsets.ModelViewSet): serializer_class = VMSnapshotProductSerializer def get_queryset(self): - return VMSnapshotProduct.objects.filter(owner=self.request.user) + if self.request.user.is_superuser: + obj = VMSnapshotProduct.objects.all() + else: + obj = VMSnapshotProduct.objects.filter(owner=self.request.user) + + return obj def create(self, request): serializer = VMSnapshotProductSerializer(data=request.data, context={'request': request}) From 3cf3439f1cff8ec9d9ca70b5c1adc6e98cead0ea Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 2 Apr 2020 19:29:08 +0200 Subject: [PATCH 262/284] Move all files to _etc_based --- {bin => uncloud_etcd_based/bin}/gen-version | 0 {bin => uncloud_etcd_based/bin}/uncloud | 0 {bin => uncloud_etcd_based/bin}/uncloud-run-reinstall | 0 {conf => uncloud_etcd_based/conf}/uncloud.conf | 0 {docs => uncloud_etcd_based/docs}/Makefile | 0 {docs => uncloud_etcd_based/docs}/README.md | 0 {docs => uncloud_etcd_based/docs}/__init__.py | 0 {docs => uncloud_etcd_based/docs}/source/__init__.py | 0 {docs => uncloud_etcd_based/docs}/source/admin-guide.rst | 0 {docs => uncloud_etcd_based/docs}/source/conf.py | 0 {docs => uncloud_etcd_based/docs}/source/diagram-code/ucloud | 0 {docs => uncloud_etcd_based/docs}/source/hacking.rst | 0 {docs => uncloud_etcd_based/docs}/source/images/ucloud.svg | 0 {docs => uncloud_etcd_based/docs}/source/index.rst | 0 {docs => uncloud_etcd_based/docs}/source/introduction.rst | 0 {docs => uncloud_etcd_based/docs}/source/misc/todo.rst | 0 {docs => uncloud_etcd_based/docs}/source/setup-install.rst | 0 {docs => uncloud_etcd_based/docs}/source/theory/summary.rst | 0 {docs => uncloud_etcd_based/docs}/source/troubleshooting.rst | 0 {docs => uncloud_etcd_based/docs}/source/user-guide.rst | 0 .../source/user-guide/how-to-create-an-os-image-for-ucloud.rst | 0 {docs => uncloud_etcd_based/docs}/source/vm-images.rst | 0 {scripts => uncloud_etcd_based/scripts}/uncloud | 0 setup.py => uncloud_etcd_based/setup.py | 0 {test => uncloud_etcd_based/test}/__init__.py | 0 {test => uncloud_etcd_based/test}/test_mac_local.py | 0 {uncloud => uncloud_etcd_based/uncloud}/__init__.py | 0 {uncloud => uncloud_etcd_based/uncloud}/api/README.md | 0 {uncloud => uncloud_etcd_based/uncloud}/api/__init__.py | 0 {uncloud => uncloud_etcd_based/uncloud}/api/common_fields.py | 0 .../uncloud}/api/create_image_store.py | 0 {uncloud => uncloud_etcd_based/uncloud}/api/helper.py | 0 {uncloud => uncloud_etcd_based/uncloud}/api/main.py | 0 {uncloud => uncloud_etcd_based/uncloud}/api/schemas.py | 0 {uncloud => uncloud_etcd_based/uncloud}/cli/__init__.py | 0 {uncloud => uncloud_etcd_based/uncloud}/cli/helper.py | 0 {uncloud => uncloud_etcd_based/uncloud}/cli/host.py | 0 {uncloud => uncloud_etcd_based/uncloud}/cli/image.py | 0 {uncloud => uncloud_etcd_based/uncloud}/cli/main.py | 0 {uncloud => uncloud_etcd_based/uncloud}/cli/network.py | 0 {uncloud => uncloud_etcd_based/uncloud}/cli/user.py | 0 {uncloud => uncloud_etcd_based/uncloud}/cli/vm.py | 0 {uncloud => uncloud_etcd_based/uncloud}/client/__init__.py | 0 {uncloud => uncloud_etcd_based/uncloud}/client/main.py | 0 {uncloud => uncloud_etcd_based/uncloud}/common/__init__.py | 0 {uncloud => uncloud_etcd_based/uncloud}/common/classes.py | 0 {uncloud => uncloud_etcd_based/uncloud}/common/cli.py | 0 {uncloud => uncloud_etcd_based/uncloud}/common/counters.py | 0 {uncloud => uncloud_etcd_based/uncloud}/common/etcd_wrapper.py | 0 {uncloud => uncloud_etcd_based/uncloud}/common/host.py | 0 {uncloud => uncloud_etcd_based/uncloud}/common/network.py | 0 {uncloud => uncloud_etcd_based/uncloud}/common/parser.py | 0 {uncloud => uncloud_etcd_based/uncloud}/common/request.py | 0 {uncloud => uncloud_etcd_based/uncloud}/common/schemas.py | 0 {uncloud => uncloud_etcd_based/uncloud}/common/settings.py | 0 {uncloud => uncloud_etcd_based/uncloud}/common/shared.py | 0 .../uncloud}/common/storage_handlers.py | 0 {uncloud => uncloud_etcd_based/uncloud}/common/vm.py | 0 {uncloud => uncloud_etcd_based/uncloud}/configure/__init__.py | 0 {uncloud => uncloud_etcd_based/uncloud}/configure/main.py | 0 {uncloud => uncloud_etcd_based/uncloud}/filescanner/__init__.py | 0 {uncloud => uncloud_etcd_based/uncloud}/filescanner/main.py | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/README.org | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/__init__.py | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/conf.d/ucloud-host | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/config.py | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/db.py | 0 .../uncloud}/hack/hackcloud/.gitignore | 0 .../uncloud}/hack/hackcloud/__init__.py | 0 .../uncloud}/hack/hackcloud/etcd-client.sh | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/ifdown.sh | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/ifup.sh | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/mac-last | 0 .../uncloud}/hack/hackcloud/mac-prefix | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/net.sh | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/nftrules | 0 .../uncloud}/hack/hackcloud/radvd.conf | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/radvd.sh | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/vm.sh | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/host.py | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/mac.py | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/main.py | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/net.py | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/nftables.conf | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/product.py | 0 .../uncloud}/hack/rc-scripts/ucloud-api | 0 .../uncloud}/hack/rc-scripts/ucloud-host | 0 .../uncloud}/hack/rc-scripts/ucloud-metadata | 0 .../uncloud}/hack/rc-scripts/ucloud-scheduler | 0 .../uncloud}/hack/uncloud-hack-init-host | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/uncloud-run-vm | 0 {uncloud => uncloud_etcd_based/uncloud}/hack/vm.py | 0 {uncloud => uncloud_etcd_based/uncloud}/host/__init__.py | 0 {uncloud => uncloud_etcd_based/uncloud}/host/main.py | 0 {uncloud => uncloud_etcd_based/uncloud}/host/virtualmachine.py | 0 {uncloud => uncloud_etcd_based/uncloud}/imagescanner/__init__.py | 0 {uncloud => uncloud_etcd_based/uncloud}/imagescanner/main.py | 0 {uncloud => uncloud_etcd_based/uncloud}/metadata/__init__.py | 0 {uncloud => uncloud_etcd_based/uncloud}/metadata/main.py | 0 {uncloud => uncloud_etcd_based/uncloud}/network/README | 0 {uncloud => uncloud_etcd_based/uncloud}/network/__init__.py | 0 {uncloud => uncloud_etcd_based/uncloud}/network/create-bridge.sh | 0 {uncloud => uncloud_etcd_based/uncloud}/network/create-tap.sh | 0 {uncloud => uncloud_etcd_based/uncloud}/network/create-vxlan.sh | 0 .../uncloud}/network/radvd-template.conf | 0 {uncloud => uncloud_etcd_based/uncloud}/oneshot/__init__.py | 0 {uncloud => uncloud_etcd_based/uncloud}/oneshot/main.py | 0 .../uncloud}/oneshot/virtualmachine.py | 0 {uncloud => uncloud_etcd_based/uncloud}/scheduler/__init__.py | 0 {uncloud => uncloud_etcd_based/uncloud}/scheduler/helper.py | 0 {uncloud => uncloud_etcd_based/uncloud}/scheduler/main.py | 0 .../uncloud}/scheduler/tests/__init__.py | 0 .../uncloud}/scheduler/tests/test_basics.py | 0 .../uncloud}/scheduler/tests/test_dead_host_mechanism.py | 0 uncloud_etcd_based/uncloud/version.py | 1 + {uncloud => uncloud_etcd_based/uncloud}/vmm/__init__.py | 0 116 files changed, 1 insertion(+) rename {bin => uncloud_etcd_based/bin}/gen-version (100%) rename {bin => uncloud_etcd_based/bin}/uncloud (100%) rename {bin => uncloud_etcd_based/bin}/uncloud-run-reinstall (100%) rename {conf => uncloud_etcd_based/conf}/uncloud.conf (100%) rename {docs => uncloud_etcd_based/docs}/Makefile (100%) rename {docs => uncloud_etcd_based/docs}/README.md (100%) rename {docs => uncloud_etcd_based/docs}/__init__.py (100%) rename {docs => uncloud_etcd_based/docs}/source/__init__.py (100%) rename {docs => uncloud_etcd_based/docs}/source/admin-guide.rst (100%) rename {docs => uncloud_etcd_based/docs}/source/conf.py (100%) rename {docs => uncloud_etcd_based/docs}/source/diagram-code/ucloud (100%) rename {docs => uncloud_etcd_based/docs}/source/hacking.rst (100%) rename {docs => uncloud_etcd_based/docs}/source/images/ucloud.svg (100%) rename {docs => uncloud_etcd_based/docs}/source/index.rst (100%) rename {docs => uncloud_etcd_based/docs}/source/introduction.rst (100%) rename {docs => uncloud_etcd_based/docs}/source/misc/todo.rst (100%) rename {docs => uncloud_etcd_based/docs}/source/setup-install.rst (100%) rename {docs => uncloud_etcd_based/docs}/source/theory/summary.rst (100%) rename {docs => uncloud_etcd_based/docs}/source/troubleshooting.rst (100%) rename {docs => uncloud_etcd_based/docs}/source/user-guide.rst (100%) rename {docs => uncloud_etcd_based/docs}/source/user-guide/how-to-create-an-os-image-for-ucloud.rst (100%) rename {docs => uncloud_etcd_based/docs}/source/vm-images.rst (100%) rename {scripts => uncloud_etcd_based/scripts}/uncloud (100%) rename setup.py => uncloud_etcd_based/setup.py (100%) rename {test => uncloud_etcd_based/test}/__init__.py (100%) rename {test => uncloud_etcd_based/test}/test_mac_local.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/__init__.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/api/README.md (100%) rename {uncloud => uncloud_etcd_based/uncloud}/api/__init__.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/api/common_fields.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/api/create_image_store.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/api/helper.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/api/main.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/api/schemas.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/cli/__init__.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/cli/helper.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/cli/host.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/cli/image.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/cli/main.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/cli/network.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/cli/user.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/cli/vm.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/client/__init__.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/client/main.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/common/__init__.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/common/classes.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/common/cli.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/common/counters.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/common/etcd_wrapper.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/common/host.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/common/network.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/common/parser.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/common/request.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/common/schemas.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/common/settings.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/common/shared.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/common/storage_handlers.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/common/vm.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/configure/__init__.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/configure/main.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/filescanner/__init__.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/filescanner/main.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/README.org (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/__init__.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/conf.d/ucloud-host (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/config.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/db.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/.gitignore (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/__init__.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/etcd-client.sh (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/ifdown.sh (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/ifup.sh (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/mac-last (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/mac-prefix (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/net.sh (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/nftrules (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/radvd.conf (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/radvd.sh (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/hackcloud/vm.sh (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/host.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/mac.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/main.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/net.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/nftables.conf (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/product.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/rc-scripts/ucloud-api (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/rc-scripts/ucloud-host (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/rc-scripts/ucloud-metadata (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/rc-scripts/ucloud-scheduler (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/uncloud-hack-init-host (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/uncloud-run-vm (100%) rename {uncloud => uncloud_etcd_based/uncloud}/hack/vm.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/host/__init__.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/host/main.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/host/virtualmachine.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/imagescanner/__init__.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/imagescanner/main.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/metadata/__init__.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/metadata/main.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/network/README (100%) rename {uncloud => uncloud_etcd_based/uncloud}/network/__init__.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/network/create-bridge.sh (100%) rename {uncloud => uncloud_etcd_based/uncloud}/network/create-tap.sh (100%) rename {uncloud => uncloud_etcd_based/uncloud}/network/create-vxlan.sh (100%) rename {uncloud => uncloud_etcd_based/uncloud}/network/radvd-template.conf (100%) rename {uncloud => uncloud_etcd_based/uncloud}/oneshot/__init__.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/oneshot/main.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/oneshot/virtualmachine.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/scheduler/__init__.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/scheduler/helper.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/scheduler/main.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/scheduler/tests/__init__.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/scheduler/tests/test_basics.py (100%) rename {uncloud => uncloud_etcd_based/uncloud}/scheduler/tests/test_dead_host_mechanism.py (100%) create mode 100644 uncloud_etcd_based/uncloud/version.py rename {uncloud => uncloud_etcd_based/uncloud}/vmm/__init__.py (100%) diff --git a/bin/gen-version b/uncloud_etcd_based/bin/gen-version similarity index 100% rename from bin/gen-version rename to uncloud_etcd_based/bin/gen-version diff --git a/bin/uncloud b/uncloud_etcd_based/bin/uncloud similarity index 100% rename from bin/uncloud rename to uncloud_etcd_based/bin/uncloud diff --git a/bin/uncloud-run-reinstall b/uncloud_etcd_based/bin/uncloud-run-reinstall similarity index 100% rename from bin/uncloud-run-reinstall rename to uncloud_etcd_based/bin/uncloud-run-reinstall diff --git a/conf/uncloud.conf b/uncloud_etcd_based/conf/uncloud.conf similarity index 100% rename from conf/uncloud.conf rename to uncloud_etcd_based/conf/uncloud.conf diff --git a/docs/Makefile b/uncloud_etcd_based/docs/Makefile similarity index 100% rename from docs/Makefile rename to uncloud_etcd_based/docs/Makefile diff --git a/docs/README.md b/uncloud_etcd_based/docs/README.md similarity index 100% rename from docs/README.md rename to uncloud_etcd_based/docs/README.md diff --git a/docs/__init__.py b/uncloud_etcd_based/docs/__init__.py similarity index 100% rename from docs/__init__.py rename to uncloud_etcd_based/docs/__init__.py diff --git a/docs/source/__init__.py b/uncloud_etcd_based/docs/source/__init__.py similarity index 100% rename from docs/source/__init__.py rename to uncloud_etcd_based/docs/source/__init__.py diff --git a/docs/source/admin-guide.rst b/uncloud_etcd_based/docs/source/admin-guide.rst similarity index 100% rename from docs/source/admin-guide.rst rename to uncloud_etcd_based/docs/source/admin-guide.rst diff --git a/docs/source/conf.py b/uncloud_etcd_based/docs/source/conf.py similarity index 100% rename from docs/source/conf.py rename to uncloud_etcd_based/docs/source/conf.py diff --git a/docs/source/diagram-code/ucloud b/uncloud_etcd_based/docs/source/diagram-code/ucloud similarity index 100% rename from docs/source/diagram-code/ucloud rename to uncloud_etcd_based/docs/source/diagram-code/ucloud diff --git a/docs/source/hacking.rst b/uncloud_etcd_based/docs/source/hacking.rst similarity index 100% rename from docs/source/hacking.rst rename to uncloud_etcd_based/docs/source/hacking.rst diff --git a/docs/source/images/ucloud.svg b/uncloud_etcd_based/docs/source/images/ucloud.svg similarity index 100% rename from docs/source/images/ucloud.svg rename to uncloud_etcd_based/docs/source/images/ucloud.svg diff --git a/docs/source/index.rst b/uncloud_etcd_based/docs/source/index.rst similarity index 100% rename from docs/source/index.rst rename to uncloud_etcd_based/docs/source/index.rst diff --git a/docs/source/introduction.rst b/uncloud_etcd_based/docs/source/introduction.rst similarity index 100% rename from docs/source/introduction.rst rename to uncloud_etcd_based/docs/source/introduction.rst diff --git a/docs/source/misc/todo.rst b/uncloud_etcd_based/docs/source/misc/todo.rst similarity index 100% rename from docs/source/misc/todo.rst rename to uncloud_etcd_based/docs/source/misc/todo.rst diff --git a/docs/source/setup-install.rst b/uncloud_etcd_based/docs/source/setup-install.rst similarity index 100% rename from docs/source/setup-install.rst rename to uncloud_etcd_based/docs/source/setup-install.rst diff --git a/docs/source/theory/summary.rst b/uncloud_etcd_based/docs/source/theory/summary.rst similarity index 100% rename from docs/source/theory/summary.rst rename to uncloud_etcd_based/docs/source/theory/summary.rst diff --git a/docs/source/troubleshooting.rst b/uncloud_etcd_based/docs/source/troubleshooting.rst similarity index 100% rename from docs/source/troubleshooting.rst rename to uncloud_etcd_based/docs/source/troubleshooting.rst diff --git a/docs/source/user-guide.rst b/uncloud_etcd_based/docs/source/user-guide.rst similarity index 100% rename from docs/source/user-guide.rst rename to uncloud_etcd_based/docs/source/user-guide.rst diff --git a/docs/source/user-guide/how-to-create-an-os-image-for-ucloud.rst b/uncloud_etcd_based/docs/source/user-guide/how-to-create-an-os-image-for-ucloud.rst similarity index 100% rename from docs/source/user-guide/how-to-create-an-os-image-for-ucloud.rst rename to uncloud_etcd_based/docs/source/user-guide/how-to-create-an-os-image-for-ucloud.rst diff --git a/docs/source/vm-images.rst b/uncloud_etcd_based/docs/source/vm-images.rst similarity index 100% rename from docs/source/vm-images.rst rename to uncloud_etcd_based/docs/source/vm-images.rst diff --git a/scripts/uncloud b/uncloud_etcd_based/scripts/uncloud similarity index 100% rename from scripts/uncloud rename to uncloud_etcd_based/scripts/uncloud diff --git a/setup.py b/uncloud_etcd_based/setup.py similarity index 100% rename from setup.py rename to uncloud_etcd_based/setup.py diff --git a/test/__init__.py b/uncloud_etcd_based/test/__init__.py similarity index 100% rename from test/__init__.py rename to uncloud_etcd_based/test/__init__.py diff --git a/test/test_mac_local.py b/uncloud_etcd_based/test/test_mac_local.py similarity index 100% rename from test/test_mac_local.py rename to uncloud_etcd_based/test/test_mac_local.py diff --git a/uncloud/__init__.py b/uncloud_etcd_based/uncloud/__init__.py similarity index 100% rename from uncloud/__init__.py rename to uncloud_etcd_based/uncloud/__init__.py diff --git a/uncloud/api/README.md b/uncloud_etcd_based/uncloud/api/README.md similarity index 100% rename from uncloud/api/README.md rename to uncloud_etcd_based/uncloud/api/README.md diff --git a/uncloud/api/__init__.py b/uncloud_etcd_based/uncloud/api/__init__.py similarity index 100% rename from uncloud/api/__init__.py rename to uncloud_etcd_based/uncloud/api/__init__.py diff --git a/uncloud/api/common_fields.py b/uncloud_etcd_based/uncloud/api/common_fields.py similarity index 100% rename from uncloud/api/common_fields.py rename to uncloud_etcd_based/uncloud/api/common_fields.py diff --git a/uncloud/api/create_image_store.py b/uncloud_etcd_based/uncloud/api/create_image_store.py similarity index 100% rename from uncloud/api/create_image_store.py rename to uncloud_etcd_based/uncloud/api/create_image_store.py diff --git a/uncloud/api/helper.py b/uncloud_etcd_based/uncloud/api/helper.py similarity index 100% rename from uncloud/api/helper.py rename to uncloud_etcd_based/uncloud/api/helper.py diff --git a/uncloud/api/main.py b/uncloud_etcd_based/uncloud/api/main.py similarity index 100% rename from uncloud/api/main.py rename to uncloud_etcd_based/uncloud/api/main.py diff --git a/uncloud/api/schemas.py b/uncloud_etcd_based/uncloud/api/schemas.py similarity index 100% rename from uncloud/api/schemas.py rename to uncloud_etcd_based/uncloud/api/schemas.py diff --git a/uncloud/cli/__init__.py b/uncloud_etcd_based/uncloud/cli/__init__.py similarity index 100% rename from uncloud/cli/__init__.py rename to uncloud_etcd_based/uncloud/cli/__init__.py diff --git a/uncloud/cli/helper.py b/uncloud_etcd_based/uncloud/cli/helper.py similarity index 100% rename from uncloud/cli/helper.py rename to uncloud_etcd_based/uncloud/cli/helper.py diff --git a/uncloud/cli/host.py b/uncloud_etcd_based/uncloud/cli/host.py similarity index 100% rename from uncloud/cli/host.py rename to uncloud_etcd_based/uncloud/cli/host.py diff --git a/uncloud/cli/image.py b/uncloud_etcd_based/uncloud/cli/image.py similarity index 100% rename from uncloud/cli/image.py rename to uncloud_etcd_based/uncloud/cli/image.py diff --git a/uncloud/cli/main.py b/uncloud_etcd_based/uncloud/cli/main.py similarity index 100% rename from uncloud/cli/main.py rename to uncloud_etcd_based/uncloud/cli/main.py diff --git a/uncloud/cli/network.py b/uncloud_etcd_based/uncloud/cli/network.py similarity index 100% rename from uncloud/cli/network.py rename to uncloud_etcd_based/uncloud/cli/network.py diff --git a/uncloud/cli/user.py b/uncloud_etcd_based/uncloud/cli/user.py similarity index 100% rename from uncloud/cli/user.py rename to uncloud_etcd_based/uncloud/cli/user.py diff --git a/uncloud/cli/vm.py b/uncloud_etcd_based/uncloud/cli/vm.py similarity index 100% rename from uncloud/cli/vm.py rename to uncloud_etcd_based/uncloud/cli/vm.py diff --git a/uncloud/client/__init__.py b/uncloud_etcd_based/uncloud/client/__init__.py similarity index 100% rename from uncloud/client/__init__.py rename to uncloud_etcd_based/uncloud/client/__init__.py diff --git a/uncloud/client/main.py b/uncloud_etcd_based/uncloud/client/main.py similarity index 100% rename from uncloud/client/main.py rename to uncloud_etcd_based/uncloud/client/main.py diff --git a/uncloud/common/__init__.py b/uncloud_etcd_based/uncloud/common/__init__.py similarity index 100% rename from uncloud/common/__init__.py rename to uncloud_etcd_based/uncloud/common/__init__.py diff --git a/uncloud/common/classes.py b/uncloud_etcd_based/uncloud/common/classes.py similarity index 100% rename from uncloud/common/classes.py rename to uncloud_etcd_based/uncloud/common/classes.py diff --git a/uncloud/common/cli.py b/uncloud_etcd_based/uncloud/common/cli.py similarity index 100% rename from uncloud/common/cli.py rename to uncloud_etcd_based/uncloud/common/cli.py diff --git a/uncloud/common/counters.py b/uncloud_etcd_based/uncloud/common/counters.py similarity index 100% rename from uncloud/common/counters.py rename to uncloud_etcd_based/uncloud/common/counters.py diff --git a/uncloud/common/etcd_wrapper.py b/uncloud_etcd_based/uncloud/common/etcd_wrapper.py similarity index 100% rename from uncloud/common/etcd_wrapper.py rename to uncloud_etcd_based/uncloud/common/etcd_wrapper.py diff --git a/uncloud/common/host.py b/uncloud_etcd_based/uncloud/common/host.py similarity index 100% rename from uncloud/common/host.py rename to uncloud_etcd_based/uncloud/common/host.py diff --git a/uncloud/common/network.py b/uncloud_etcd_based/uncloud/common/network.py similarity index 100% rename from uncloud/common/network.py rename to uncloud_etcd_based/uncloud/common/network.py diff --git a/uncloud/common/parser.py b/uncloud_etcd_based/uncloud/common/parser.py similarity index 100% rename from uncloud/common/parser.py rename to uncloud_etcd_based/uncloud/common/parser.py diff --git a/uncloud/common/request.py b/uncloud_etcd_based/uncloud/common/request.py similarity index 100% rename from uncloud/common/request.py rename to uncloud_etcd_based/uncloud/common/request.py diff --git a/uncloud/common/schemas.py b/uncloud_etcd_based/uncloud/common/schemas.py similarity index 100% rename from uncloud/common/schemas.py rename to uncloud_etcd_based/uncloud/common/schemas.py diff --git a/uncloud/common/settings.py b/uncloud_etcd_based/uncloud/common/settings.py similarity index 100% rename from uncloud/common/settings.py rename to uncloud_etcd_based/uncloud/common/settings.py diff --git a/uncloud/common/shared.py b/uncloud_etcd_based/uncloud/common/shared.py similarity index 100% rename from uncloud/common/shared.py rename to uncloud_etcd_based/uncloud/common/shared.py diff --git a/uncloud/common/storage_handlers.py b/uncloud_etcd_based/uncloud/common/storage_handlers.py similarity index 100% rename from uncloud/common/storage_handlers.py rename to uncloud_etcd_based/uncloud/common/storage_handlers.py diff --git a/uncloud/common/vm.py b/uncloud_etcd_based/uncloud/common/vm.py similarity index 100% rename from uncloud/common/vm.py rename to uncloud_etcd_based/uncloud/common/vm.py diff --git a/uncloud/configure/__init__.py b/uncloud_etcd_based/uncloud/configure/__init__.py similarity index 100% rename from uncloud/configure/__init__.py rename to uncloud_etcd_based/uncloud/configure/__init__.py diff --git a/uncloud/configure/main.py b/uncloud_etcd_based/uncloud/configure/main.py similarity index 100% rename from uncloud/configure/main.py rename to uncloud_etcd_based/uncloud/configure/main.py diff --git a/uncloud/filescanner/__init__.py b/uncloud_etcd_based/uncloud/filescanner/__init__.py similarity index 100% rename from uncloud/filescanner/__init__.py rename to uncloud_etcd_based/uncloud/filescanner/__init__.py diff --git a/uncloud/filescanner/main.py b/uncloud_etcd_based/uncloud/filescanner/main.py similarity index 100% rename from uncloud/filescanner/main.py rename to uncloud_etcd_based/uncloud/filescanner/main.py diff --git a/uncloud/hack/README.org b/uncloud_etcd_based/uncloud/hack/README.org similarity index 100% rename from uncloud/hack/README.org rename to uncloud_etcd_based/uncloud/hack/README.org diff --git a/uncloud/hack/__init__.py b/uncloud_etcd_based/uncloud/hack/__init__.py similarity index 100% rename from uncloud/hack/__init__.py rename to uncloud_etcd_based/uncloud/hack/__init__.py diff --git a/uncloud/hack/conf.d/ucloud-host b/uncloud_etcd_based/uncloud/hack/conf.d/ucloud-host similarity index 100% rename from uncloud/hack/conf.d/ucloud-host rename to uncloud_etcd_based/uncloud/hack/conf.d/ucloud-host diff --git a/uncloud/hack/config.py b/uncloud_etcd_based/uncloud/hack/config.py similarity index 100% rename from uncloud/hack/config.py rename to uncloud_etcd_based/uncloud/hack/config.py diff --git a/uncloud/hack/db.py b/uncloud_etcd_based/uncloud/hack/db.py similarity index 100% rename from uncloud/hack/db.py rename to uncloud_etcd_based/uncloud/hack/db.py diff --git a/uncloud/hack/hackcloud/.gitignore b/uncloud_etcd_based/uncloud/hack/hackcloud/.gitignore similarity index 100% rename from uncloud/hack/hackcloud/.gitignore rename to uncloud_etcd_based/uncloud/hack/hackcloud/.gitignore diff --git a/uncloud/hack/hackcloud/__init__.py b/uncloud_etcd_based/uncloud/hack/hackcloud/__init__.py similarity index 100% rename from uncloud/hack/hackcloud/__init__.py rename to uncloud_etcd_based/uncloud/hack/hackcloud/__init__.py diff --git a/uncloud/hack/hackcloud/etcd-client.sh b/uncloud_etcd_based/uncloud/hack/hackcloud/etcd-client.sh similarity index 100% rename from uncloud/hack/hackcloud/etcd-client.sh rename to uncloud_etcd_based/uncloud/hack/hackcloud/etcd-client.sh diff --git a/uncloud/hack/hackcloud/ifdown.sh b/uncloud_etcd_based/uncloud/hack/hackcloud/ifdown.sh similarity index 100% rename from uncloud/hack/hackcloud/ifdown.sh rename to uncloud_etcd_based/uncloud/hack/hackcloud/ifdown.sh diff --git a/uncloud/hack/hackcloud/ifup.sh b/uncloud_etcd_based/uncloud/hack/hackcloud/ifup.sh similarity index 100% rename from uncloud/hack/hackcloud/ifup.sh rename to uncloud_etcd_based/uncloud/hack/hackcloud/ifup.sh diff --git a/uncloud/hack/hackcloud/mac-last b/uncloud_etcd_based/uncloud/hack/hackcloud/mac-last similarity index 100% rename from uncloud/hack/hackcloud/mac-last rename to uncloud_etcd_based/uncloud/hack/hackcloud/mac-last diff --git a/uncloud/hack/hackcloud/mac-prefix b/uncloud_etcd_based/uncloud/hack/hackcloud/mac-prefix similarity index 100% rename from uncloud/hack/hackcloud/mac-prefix rename to uncloud_etcd_based/uncloud/hack/hackcloud/mac-prefix diff --git a/uncloud/hack/hackcloud/net.sh b/uncloud_etcd_based/uncloud/hack/hackcloud/net.sh similarity index 100% rename from uncloud/hack/hackcloud/net.sh rename to uncloud_etcd_based/uncloud/hack/hackcloud/net.sh diff --git a/uncloud/hack/hackcloud/nftrules b/uncloud_etcd_based/uncloud/hack/hackcloud/nftrules similarity index 100% rename from uncloud/hack/hackcloud/nftrules rename to uncloud_etcd_based/uncloud/hack/hackcloud/nftrules diff --git a/uncloud/hack/hackcloud/radvd.conf b/uncloud_etcd_based/uncloud/hack/hackcloud/radvd.conf similarity index 100% rename from uncloud/hack/hackcloud/radvd.conf rename to uncloud_etcd_based/uncloud/hack/hackcloud/radvd.conf diff --git a/uncloud/hack/hackcloud/radvd.sh b/uncloud_etcd_based/uncloud/hack/hackcloud/radvd.sh similarity index 100% rename from uncloud/hack/hackcloud/radvd.sh rename to uncloud_etcd_based/uncloud/hack/hackcloud/radvd.sh diff --git a/uncloud/hack/hackcloud/vm.sh b/uncloud_etcd_based/uncloud/hack/hackcloud/vm.sh similarity index 100% rename from uncloud/hack/hackcloud/vm.sh rename to uncloud_etcd_based/uncloud/hack/hackcloud/vm.sh diff --git a/uncloud/hack/host.py b/uncloud_etcd_based/uncloud/hack/host.py similarity index 100% rename from uncloud/hack/host.py rename to uncloud_etcd_based/uncloud/hack/host.py diff --git a/uncloud/hack/mac.py b/uncloud_etcd_based/uncloud/hack/mac.py similarity index 100% rename from uncloud/hack/mac.py rename to uncloud_etcd_based/uncloud/hack/mac.py diff --git a/uncloud/hack/main.py b/uncloud_etcd_based/uncloud/hack/main.py similarity index 100% rename from uncloud/hack/main.py rename to uncloud_etcd_based/uncloud/hack/main.py diff --git a/uncloud/hack/net.py b/uncloud_etcd_based/uncloud/hack/net.py similarity index 100% rename from uncloud/hack/net.py rename to uncloud_etcd_based/uncloud/hack/net.py diff --git a/uncloud/hack/nftables.conf b/uncloud_etcd_based/uncloud/hack/nftables.conf similarity index 100% rename from uncloud/hack/nftables.conf rename to uncloud_etcd_based/uncloud/hack/nftables.conf diff --git a/uncloud/hack/product.py b/uncloud_etcd_based/uncloud/hack/product.py similarity index 100% rename from uncloud/hack/product.py rename to uncloud_etcd_based/uncloud/hack/product.py diff --git a/uncloud/hack/rc-scripts/ucloud-api b/uncloud_etcd_based/uncloud/hack/rc-scripts/ucloud-api similarity index 100% rename from uncloud/hack/rc-scripts/ucloud-api rename to uncloud_etcd_based/uncloud/hack/rc-scripts/ucloud-api diff --git a/uncloud/hack/rc-scripts/ucloud-host b/uncloud_etcd_based/uncloud/hack/rc-scripts/ucloud-host similarity index 100% rename from uncloud/hack/rc-scripts/ucloud-host rename to uncloud_etcd_based/uncloud/hack/rc-scripts/ucloud-host diff --git a/uncloud/hack/rc-scripts/ucloud-metadata b/uncloud_etcd_based/uncloud/hack/rc-scripts/ucloud-metadata similarity index 100% rename from uncloud/hack/rc-scripts/ucloud-metadata rename to uncloud_etcd_based/uncloud/hack/rc-scripts/ucloud-metadata diff --git a/uncloud/hack/rc-scripts/ucloud-scheduler b/uncloud_etcd_based/uncloud/hack/rc-scripts/ucloud-scheduler similarity index 100% rename from uncloud/hack/rc-scripts/ucloud-scheduler rename to uncloud_etcd_based/uncloud/hack/rc-scripts/ucloud-scheduler diff --git a/uncloud/hack/uncloud-hack-init-host b/uncloud_etcd_based/uncloud/hack/uncloud-hack-init-host similarity index 100% rename from uncloud/hack/uncloud-hack-init-host rename to uncloud_etcd_based/uncloud/hack/uncloud-hack-init-host diff --git a/uncloud/hack/uncloud-run-vm b/uncloud_etcd_based/uncloud/hack/uncloud-run-vm similarity index 100% rename from uncloud/hack/uncloud-run-vm rename to uncloud_etcd_based/uncloud/hack/uncloud-run-vm diff --git a/uncloud/hack/vm.py b/uncloud_etcd_based/uncloud/hack/vm.py similarity index 100% rename from uncloud/hack/vm.py rename to uncloud_etcd_based/uncloud/hack/vm.py diff --git a/uncloud/host/__init__.py b/uncloud_etcd_based/uncloud/host/__init__.py similarity index 100% rename from uncloud/host/__init__.py rename to uncloud_etcd_based/uncloud/host/__init__.py diff --git a/uncloud/host/main.py b/uncloud_etcd_based/uncloud/host/main.py similarity index 100% rename from uncloud/host/main.py rename to uncloud_etcd_based/uncloud/host/main.py diff --git a/uncloud/host/virtualmachine.py b/uncloud_etcd_based/uncloud/host/virtualmachine.py similarity index 100% rename from uncloud/host/virtualmachine.py rename to uncloud_etcd_based/uncloud/host/virtualmachine.py diff --git a/uncloud/imagescanner/__init__.py b/uncloud_etcd_based/uncloud/imagescanner/__init__.py similarity index 100% rename from uncloud/imagescanner/__init__.py rename to uncloud_etcd_based/uncloud/imagescanner/__init__.py diff --git a/uncloud/imagescanner/main.py b/uncloud_etcd_based/uncloud/imagescanner/main.py similarity index 100% rename from uncloud/imagescanner/main.py rename to uncloud_etcd_based/uncloud/imagescanner/main.py diff --git a/uncloud/metadata/__init__.py b/uncloud_etcd_based/uncloud/metadata/__init__.py similarity index 100% rename from uncloud/metadata/__init__.py rename to uncloud_etcd_based/uncloud/metadata/__init__.py diff --git a/uncloud/metadata/main.py b/uncloud_etcd_based/uncloud/metadata/main.py similarity index 100% rename from uncloud/metadata/main.py rename to uncloud_etcd_based/uncloud/metadata/main.py diff --git a/uncloud/network/README b/uncloud_etcd_based/uncloud/network/README similarity index 100% rename from uncloud/network/README rename to uncloud_etcd_based/uncloud/network/README diff --git a/uncloud/network/__init__.py b/uncloud_etcd_based/uncloud/network/__init__.py similarity index 100% rename from uncloud/network/__init__.py rename to uncloud_etcd_based/uncloud/network/__init__.py diff --git a/uncloud/network/create-bridge.sh b/uncloud_etcd_based/uncloud/network/create-bridge.sh similarity index 100% rename from uncloud/network/create-bridge.sh rename to uncloud_etcd_based/uncloud/network/create-bridge.sh diff --git a/uncloud/network/create-tap.sh b/uncloud_etcd_based/uncloud/network/create-tap.sh similarity index 100% rename from uncloud/network/create-tap.sh rename to uncloud_etcd_based/uncloud/network/create-tap.sh diff --git a/uncloud/network/create-vxlan.sh b/uncloud_etcd_based/uncloud/network/create-vxlan.sh similarity index 100% rename from uncloud/network/create-vxlan.sh rename to uncloud_etcd_based/uncloud/network/create-vxlan.sh diff --git a/uncloud/network/radvd-template.conf b/uncloud_etcd_based/uncloud/network/radvd-template.conf similarity index 100% rename from uncloud/network/radvd-template.conf rename to uncloud_etcd_based/uncloud/network/radvd-template.conf diff --git a/uncloud/oneshot/__init__.py b/uncloud_etcd_based/uncloud/oneshot/__init__.py similarity index 100% rename from uncloud/oneshot/__init__.py rename to uncloud_etcd_based/uncloud/oneshot/__init__.py diff --git a/uncloud/oneshot/main.py b/uncloud_etcd_based/uncloud/oneshot/main.py similarity index 100% rename from uncloud/oneshot/main.py rename to uncloud_etcd_based/uncloud/oneshot/main.py diff --git a/uncloud/oneshot/virtualmachine.py b/uncloud_etcd_based/uncloud/oneshot/virtualmachine.py similarity index 100% rename from uncloud/oneshot/virtualmachine.py rename to uncloud_etcd_based/uncloud/oneshot/virtualmachine.py diff --git a/uncloud/scheduler/__init__.py b/uncloud_etcd_based/uncloud/scheduler/__init__.py similarity index 100% rename from uncloud/scheduler/__init__.py rename to uncloud_etcd_based/uncloud/scheduler/__init__.py diff --git a/uncloud/scheduler/helper.py b/uncloud_etcd_based/uncloud/scheduler/helper.py similarity index 100% rename from uncloud/scheduler/helper.py rename to uncloud_etcd_based/uncloud/scheduler/helper.py diff --git a/uncloud/scheduler/main.py b/uncloud_etcd_based/uncloud/scheduler/main.py similarity index 100% rename from uncloud/scheduler/main.py rename to uncloud_etcd_based/uncloud/scheduler/main.py diff --git a/uncloud/scheduler/tests/__init__.py b/uncloud_etcd_based/uncloud/scheduler/tests/__init__.py similarity index 100% rename from uncloud/scheduler/tests/__init__.py rename to uncloud_etcd_based/uncloud/scheduler/tests/__init__.py diff --git a/uncloud/scheduler/tests/test_basics.py b/uncloud_etcd_based/uncloud/scheduler/tests/test_basics.py similarity index 100% rename from uncloud/scheduler/tests/test_basics.py rename to uncloud_etcd_based/uncloud/scheduler/tests/test_basics.py diff --git a/uncloud/scheduler/tests/test_dead_host_mechanism.py b/uncloud_etcd_based/uncloud/scheduler/tests/test_dead_host_mechanism.py similarity index 100% rename from uncloud/scheduler/tests/test_dead_host_mechanism.py rename to uncloud_etcd_based/uncloud/scheduler/tests/test_dead_host_mechanism.py diff --git a/uncloud_etcd_based/uncloud/version.py b/uncloud_etcd_based/uncloud/version.py new file mode 100644 index 0000000..ccf3980 --- /dev/null +++ b/uncloud_etcd_based/uncloud/version.py @@ -0,0 +1 @@ +VERSION = "0.0.5-30-ge91fd9e" diff --git a/uncloud/vmm/__init__.py b/uncloud_etcd_based/uncloud/vmm/__init__.py similarity index 100% rename from uncloud/vmm/__init__.py rename to uncloud_etcd_based/uncloud/vmm/__init__.py From 833d57047228155af7a66e6c698e8717e6456799 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 2 Apr 2020 19:30:47 +0200 Subject: [PATCH 263/284] sync .gitignore --- .gitignore | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 6f0d9df..cbb171f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ -.idea -.vscode +.idea/ +.vscode/ +__pycache__/ +pay.conf +log.txt +test.py +STRIPE +venv/ uncloud/docs/build logs.txt From 7a6c8739f6652f588f62517db6809a593139eafd Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 2 Apr 2020 19:31:03 +0200 Subject: [PATCH 264/284] Rename / prepare for merge with uncloud repo --- .gitignore | 17 + .../abk-hacks.py | 0 .../abkhack}/opennebula_hacks.py | 0 .../meow-payv1}/README.md | 0 .../meow-payv1}/config.py | 0 .../meow-payv1}/hack-a-vpn.py | 0 .../meow-payv1}/helper.py | 0 .../meow-payv1}/ldaptest.py | 0 .../products/ipv6-only-django.json | 0 .../meow-payv1}/products/ipv6-only-vm.json | 0 .../meow-payv1}/products/ipv6-only-vpn.json | 0 .../meow-payv1}/products/ipv6box.json | 0 .../meow-payv1}/products/membership.json | 0 .../meow-payv1}/requirements.txt | 0 .../meow-payv1}/sample-pay.conf | 0 .../meow-payv1}/schemas.py | 0 .../meow-payv1}/stripe_hack.py | 0 .../meow-payv1}/stripe_utils.py | 0 .../meow-payv1}/ucloud_pay.py | 0 .../notes-abk.md | 0 .../notes-nico.org | 0 plan.org => uncloud_django_based/plan.org | 0 .../uncloud}/.gitignore | 0 .../README-how-to-create-a-product.md | 0 .../uncloud}/README-object-relations.md | 0 .../uncloud}/README.md | 0 .../uncloud}/manage.py | 0 uncloud_django_based/uncloud/models.dot | 1482 +++++++++++++++++ uncloud_django_based/uncloud/models.png | Bin 0 -> 408110 bytes .../uncloud}/opennebula/__init__.py | 0 .../uncloud}/opennebula/admin.py | 0 .../uncloud}/opennebula/apps.py | 0 .../commands/opennebula-synchosts.py | 0 .../management/commands/opennebula-syncvms.py | 0 .../commands/opennebula-to-uncloud.py | 0 .../opennebula/migrations/0001_initial.py | 0 .../migrations/0002_auto_20200225_1335.py | 0 .../migrations/0003_auto_20200225_1428.py | 0 .../migrations/0004_auto_20200225_1816.py | 0 .../opennebula/migrations/__init__.py | 0 .../uncloud}/opennebula/models.py | 0 .../uncloud}/opennebula/serializers.py | 0 .../uncloud}/opennebula/tests.py | 0 .../uncloud}/opennebula/views.py | 0 .../uncloud}/requirements.txt | 0 .../uncloud}/uncloud/.gitignore | 0 .../uncloud}/uncloud/__init__.py | 0 .../uncloud}/uncloud/asgi.py | 0 .../uncloud/management/commands/uncloud.py | 0 .../uncloud}/uncloud/models.py | 0 .../uncloud}/uncloud/secrets_sample.py | 0 .../uncloud}/uncloud/settings.py | 0 .../uncloud}/uncloud/urls.py | 0 .../uncloud}/uncloud/wsgi.py | 0 .../uncloud}/uncloud_auth/__init__.py | 0 .../uncloud}/uncloud_auth/admin.py | 0 .../uncloud}/uncloud_auth/apps.py | 0 .../uncloud_auth/migrations/0001_initial.py | 0 .../migrations/0002_auto_20200318_1343.py | 0 .../migrations/0003_auto_20200318_1345.py | 0 .../uncloud_auth/migrations/__init__.py | 0 .../uncloud}/uncloud_auth/models.py | 0 .../uncloud}/uncloud_auth/serializers.py | 0 .../uncloud}/uncloud_auth/views.py | 0 .../uncloud}/uncloud_net/__init__.py | 0 .../uncloud}/uncloud_net/admin.py | 0 .../uncloud}/uncloud_net/apps.py | 0 .../uncloud}/uncloud_net/models.py | 0 .../uncloud}/uncloud_net/tests.py | 0 .../uncloud}/uncloud_net/views.py | 0 .../uncloud}/uncloud_pay/__init__.py | 0 .../uncloud}/uncloud_pay/admin.py | 0 .../uncloud}/uncloud_pay/apps.py | 0 .../uncloud}/uncloud_pay/helpers.py | 0 .../commands/charge-negative-balance.py | 0 .../management/commands/generate-bills.py | 0 .../commands/handle-overdue-bills.py | 0 .../uncloud_pay/migrations/0001_initial.py | 0 .../uncloud_pay/migrations/__init__.py | 0 .../uncloud}/uncloud_pay/models.py | 0 .../uncloud}/uncloud_pay/serializers.py | 0 .../uncloud}/uncloud_pay/stripe.py | 0 .../uncloud}/uncloud_pay/tests.py | 0 .../uncloud}/uncloud_pay/views.py | 0 .../uncloud}/uncloud_storage/__init__.py | 0 .../uncloud}/uncloud_storage/admin.py | 0 .../uncloud}/uncloud_storage/apps.py | 0 .../uncloud}/uncloud_storage/models.py | 0 .../uncloud}/uncloud_storage/tests.py | 0 .../uncloud}/uncloud_storage/views.py | 0 .../uncloud}/uncloud_vm/__init__.py | 0 .../uncloud}/uncloud_vm/admin.py | 0 .../uncloud}/uncloud_vm/apps.py | 0 .../uncloud_vm/management/commands/vm.py | 0 .../uncloud_vm/migrations/0001_initial.py | 0 .../migrations/0002_auto_20200305_1321.py | 0 .../migrations/0003_remove_vmhost_vms.py | 0 .../migrations/0004_remove_vmproduct_vmid.py | 0 .../migrations/0005_auto_20200321_1058.py | 0 .../migrations/0006_auto_20200322_1758.py | 0 .../migrations/0007_vmhost_vmcluster.py | 0 .../uncloud_vm/migrations/__init__.py | 0 .../uncloud}/uncloud_vm/models.py | 0 .../uncloud}/uncloud_vm/serializers.py | 0 .../uncloud}/uncloud_vm/tests.py | 0 .../uncloud}/uncloud_vm/views.py | 0 .../uncloud}/ungleich_service/__init__.py | 0 .../uncloud}/ungleich_service/admin.py | 0 .../uncloud}/ungleich_service/apps.py | 0 .../migrations/0001_initial.py | 0 .../0002_matrixserviceproduct_extra_data.py | 0 .../migrations/0003_auto_20200322_1758.py | 0 .../ungleich_service/migrations/__init__.py | 0 .../uncloud}/ungleich_service/models.py | 0 .../uncloud}/ungleich_service/serializers.py | 0 .../uncloud}/ungleich_service/tests.py | 0 .../uncloud}/ungleich_service/views.py | 0 .../vat_rates.csv | 0 118 files changed, 1499 insertions(+) rename abk-hacks.py => uncloud_django_based/abk-hacks.py (100%) rename {abkhack => uncloud_django_based/abkhack}/opennebula_hacks.py (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/README.md (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/config.py (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/hack-a-vpn.py (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/helper.py (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/ldaptest.py (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/products/ipv6-only-django.json (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/products/ipv6-only-vm.json (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/products/ipv6-only-vpn.json (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/products/ipv6box.json (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/products/membership.json (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/requirements.txt (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/sample-pay.conf (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/schemas.py (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/stripe_hack.py (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/stripe_utils.py (100%) rename {meow-payv1 => uncloud_django_based/meow-payv1}/ucloud_pay.py (100%) rename notes-abk.md => uncloud_django_based/notes-abk.md (100%) rename notes-nico.org => uncloud_django_based/notes-nico.org (100%) rename plan.org => uncloud_django_based/plan.org (100%) rename {uncloud => uncloud_django_based/uncloud}/.gitignore (100%) rename {uncloud => uncloud_django_based/uncloud}/README-how-to-create-a-product.md (100%) rename {uncloud => uncloud_django_based/uncloud}/README-object-relations.md (100%) rename {uncloud => uncloud_django_based/uncloud}/README.md (100%) rename {uncloud => uncloud_django_based/uncloud}/manage.py (100%) create mode 100644 uncloud_django_based/uncloud/models.dot create mode 100644 uncloud_django_based/uncloud/models.png rename {uncloud => uncloud_django_based/uncloud}/opennebula/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/admin.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/apps.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/management/commands/opennebula-synchosts.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/management/commands/opennebula-syncvms.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/management/commands/opennebula-to-uncloud.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/migrations/0001_initial.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/migrations/0002_auto_20200225_1335.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/migrations/0003_auto_20200225_1428.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/migrations/0004_auto_20200225_1816.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/migrations/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/models.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/serializers.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/tests.py (100%) rename {uncloud => uncloud_django_based/uncloud}/opennebula/views.py (100%) rename {uncloud => uncloud_django_based/uncloud}/requirements.txt (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud/.gitignore (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud/asgi.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud/management/commands/uncloud.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud/models.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud/secrets_sample.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud/settings.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud/urls.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud/wsgi.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/admin.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/apps.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/migrations/0001_initial.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/migrations/0002_auto_20200318_1343.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/migrations/0003_auto_20200318_1345.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/migrations/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/models.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/serializers.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_auth/views.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_net/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_net/admin.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_net/apps.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_net/models.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_net/tests.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_net/views.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/admin.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/apps.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/helpers.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/management/commands/charge-negative-balance.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/management/commands/generate-bills.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/management/commands/handle-overdue-bills.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/migrations/0001_initial.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/migrations/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/models.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/serializers.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/stripe.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/tests.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_pay/views.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_storage/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_storage/admin.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_storage/apps.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_storage/models.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_storage/tests.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_storage/views.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/admin.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/apps.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/management/commands/vm.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/migrations/0001_initial.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/migrations/0002_auto_20200305_1321.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/migrations/0003_remove_vmhost_vms.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/migrations/0005_auto_20200321_1058.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/migrations/0006_auto_20200322_1758.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/migrations/0007_vmhost_vmcluster.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/migrations/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/models.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/serializers.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/tests.py (100%) rename {uncloud => uncloud_django_based/uncloud}/uncloud_vm/views.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/admin.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/apps.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/migrations/0001_initial.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/migrations/0003_auto_20200322_1758.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/migrations/__init__.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/models.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/serializers.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/tests.py (100%) rename {uncloud => uncloud_django_based/uncloud}/ungleich_service/views.py (100%) rename vat_rates.csv => uncloud_django_based/vat_rates.csv (100%) diff --git a/.gitignore b/.gitignore index 786a584..cbb171f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,20 @@ log.txt test.py STRIPE venv/ + +uncloud/docs/build +logs.txt + +uncloud.egg-info + +# run artefacts +default.etcd +__pycache__ + +# build artefacts +uncloud/version.py +build/ +venv/ +dist/ + +*.iso diff --git a/abk-hacks.py b/uncloud_django_based/abk-hacks.py similarity index 100% rename from abk-hacks.py rename to uncloud_django_based/abk-hacks.py diff --git a/abkhack/opennebula_hacks.py b/uncloud_django_based/abkhack/opennebula_hacks.py similarity index 100% rename from abkhack/opennebula_hacks.py rename to uncloud_django_based/abkhack/opennebula_hacks.py diff --git a/meow-payv1/README.md b/uncloud_django_based/meow-payv1/README.md similarity index 100% rename from meow-payv1/README.md rename to uncloud_django_based/meow-payv1/README.md diff --git a/meow-payv1/config.py b/uncloud_django_based/meow-payv1/config.py similarity index 100% rename from meow-payv1/config.py rename to uncloud_django_based/meow-payv1/config.py diff --git a/meow-payv1/hack-a-vpn.py b/uncloud_django_based/meow-payv1/hack-a-vpn.py similarity index 100% rename from meow-payv1/hack-a-vpn.py rename to uncloud_django_based/meow-payv1/hack-a-vpn.py diff --git a/meow-payv1/helper.py b/uncloud_django_based/meow-payv1/helper.py similarity index 100% rename from meow-payv1/helper.py rename to uncloud_django_based/meow-payv1/helper.py diff --git a/meow-payv1/ldaptest.py b/uncloud_django_based/meow-payv1/ldaptest.py similarity index 100% rename from meow-payv1/ldaptest.py rename to uncloud_django_based/meow-payv1/ldaptest.py diff --git a/meow-payv1/products/ipv6-only-django.json b/uncloud_django_based/meow-payv1/products/ipv6-only-django.json similarity index 100% rename from meow-payv1/products/ipv6-only-django.json rename to uncloud_django_based/meow-payv1/products/ipv6-only-django.json diff --git a/meow-payv1/products/ipv6-only-vm.json b/uncloud_django_based/meow-payv1/products/ipv6-only-vm.json similarity index 100% rename from meow-payv1/products/ipv6-only-vm.json rename to uncloud_django_based/meow-payv1/products/ipv6-only-vm.json diff --git a/meow-payv1/products/ipv6-only-vpn.json b/uncloud_django_based/meow-payv1/products/ipv6-only-vpn.json similarity index 100% rename from meow-payv1/products/ipv6-only-vpn.json rename to uncloud_django_based/meow-payv1/products/ipv6-only-vpn.json diff --git a/meow-payv1/products/ipv6box.json b/uncloud_django_based/meow-payv1/products/ipv6box.json similarity index 100% rename from meow-payv1/products/ipv6box.json rename to uncloud_django_based/meow-payv1/products/ipv6box.json diff --git a/meow-payv1/products/membership.json b/uncloud_django_based/meow-payv1/products/membership.json similarity index 100% rename from meow-payv1/products/membership.json rename to uncloud_django_based/meow-payv1/products/membership.json diff --git a/meow-payv1/requirements.txt b/uncloud_django_based/meow-payv1/requirements.txt similarity index 100% rename from meow-payv1/requirements.txt rename to uncloud_django_based/meow-payv1/requirements.txt diff --git a/meow-payv1/sample-pay.conf b/uncloud_django_based/meow-payv1/sample-pay.conf similarity index 100% rename from meow-payv1/sample-pay.conf rename to uncloud_django_based/meow-payv1/sample-pay.conf diff --git a/meow-payv1/schemas.py b/uncloud_django_based/meow-payv1/schemas.py similarity index 100% rename from meow-payv1/schemas.py rename to uncloud_django_based/meow-payv1/schemas.py diff --git a/meow-payv1/stripe_hack.py b/uncloud_django_based/meow-payv1/stripe_hack.py similarity index 100% rename from meow-payv1/stripe_hack.py rename to uncloud_django_based/meow-payv1/stripe_hack.py diff --git a/meow-payv1/stripe_utils.py b/uncloud_django_based/meow-payv1/stripe_utils.py similarity index 100% rename from meow-payv1/stripe_utils.py rename to uncloud_django_based/meow-payv1/stripe_utils.py diff --git a/meow-payv1/ucloud_pay.py b/uncloud_django_based/meow-payv1/ucloud_pay.py similarity index 100% rename from meow-payv1/ucloud_pay.py rename to uncloud_django_based/meow-payv1/ucloud_pay.py diff --git a/notes-abk.md b/uncloud_django_based/notes-abk.md similarity index 100% rename from notes-abk.md rename to uncloud_django_based/notes-abk.md diff --git a/notes-nico.org b/uncloud_django_based/notes-nico.org similarity index 100% rename from notes-nico.org rename to uncloud_django_based/notes-nico.org diff --git a/plan.org b/uncloud_django_based/plan.org similarity index 100% rename from plan.org rename to uncloud_django_based/plan.org diff --git a/uncloud/.gitignore b/uncloud_django_based/uncloud/.gitignore similarity index 100% rename from uncloud/.gitignore rename to uncloud_django_based/uncloud/.gitignore diff --git a/uncloud/README-how-to-create-a-product.md b/uncloud_django_based/uncloud/README-how-to-create-a-product.md similarity index 100% rename from uncloud/README-how-to-create-a-product.md rename to uncloud_django_based/uncloud/README-how-to-create-a-product.md diff --git a/uncloud/README-object-relations.md b/uncloud_django_based/uncloud/README-object-relations.md similarity index 100% rename from uncloud/README-object-relations.md rename to uncloud_django_based/uncloud/README-object-relations.md diff --git a/uncloud/README.md b/uncloud_django_based/uncloud/README.md similarity index 100% rename from uncloud/README.md rename to uncloud_django_based/uncloud/README.md diff --git a/uncloud/manage.py b/uncloud_django_based/uncloud/manage.py similarity index 100% rename from uncloud/manage.py rename to uncloud_django_based/uncloud/manage.py diff --git a/uncloud_django_based/uncloud/models.dot b/uncloud_django_based/uncloud/models.dot new file mode 100644 index 0000000..0adfba8 --- /dev/null +++ b/uncloud_django_based/uncloud/models.dot @@ -0,0 +1,1482 @@ +digraph model_graph { + // Dotfile by Django-Extensions graph_models + // Created: 2020-03-17 12:30 + // Cli Options: -a + + fontname = "Roboto" + fontsize = 8 + splines = true + + node [ + fontname = "Roboto" + fontsize = 8 + shape = "plaintext" + ] + + edge [ + fontname = "Roboto" + fontsize = 8 + ] + + // Labels + + + django_contrib_admin_models_LogEntry [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + LogEntry +
+ id + + AutoField +
+ content_type + + ForeignKey (id) +
+ user + + ForeignKey (id) +
+ action_flag + + PositiveSmallIntegerField +
+ action_time + + DateTimeField +
+ change_message + + TextField +
+ object_id + + TextField +
+ object_repr + + CharField +
+ >] + + + + + django_contrib_auth_models_Permission [label=< + + + + + + + + + + + + + + + + + + + +
+ + Permission +
+ id + + AutoField +
+ content_type + + ForeignKey (id) +
+ codename + + CharField +
+ name + + CharField +
+ >] + + django_contrib_auth_models_Group [label=< + + + + + + + + + + + +
+ + Group +
+ id + + AutoField +
+ name + + CharField +
+ >] + + + + + django_contrib_contenttypes_models_ContentType [label=< + + + + + + + + + + + + + + + +
+ + ContentType +
+ id + + AutoField +
+ app_label + + CharField +
+ model + + CharField +
+ >] + + + + + django_contrib_sessions_base_session_AbstractBaseSession [label=< + + + + + + + + + + + +
+ + AbstractBaseSession +
+ expire_date + + DateTimeField +
+ session_data + + TextField +
+ >] + + django_contrib_sessions_models_Session [label=< + + + + + + + + + + + + + + + +
+ + Session
<AbstractBaseSession> +
+ session_key + + CharField +
+ expire_date + + DateTimeField +
+ session_data + + TextField +
+ >] + + + + + uncloud_pay_models_StripeCustomer [label=< + + + + + + + + + + + +
+ + StripeCustomer +
+ owner + + OneToOneField (id) +
+ stripe_id + + CharField +
+ >] + + uncloud_pay_models_Payment [label=< + + + + + + + + + + + + + + + + + + + + + + + +
+ + Payment +
+ uuid + + UUIDField +
+ owner + + ForeignKey (id) +
+ amount + + DecimalField +
+ source + + CharField +
+ timestamp + + DateTimeField +
+ >] + + uncloud_pay_models_PaymentMethod [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + PaymentMethod +
+ uuid + + UUIDField +
+ owner + + ForeignKey (id) +
+ description + + TextField +
+ primary + + BooleanField +
+ source + + CharField +
+ stripe_card_id + + CharField +
+ >] + + uncloud_pay_models_Bill [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Bill +
+ uuid + + UUIDField +
+ owner + + ForeignKey (id) +
+ creation_date + + DateTimeField +
+ due_date + + DateField +
+ ending_date + + DateTimeField +
+ starting_date + + DateTimeField +
+ valid + + BooleanField +
+ >] + + uncloud_pay_models_Order [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Order +
+ uuid + + UUIDField +
+ owner + + ForeignKey (id) +
+ creation_date + + DateTimeField +
+ ending_date + + DateTimeField +
+ recurring_period + + CharField +
+ starting_date + + DateTimeField +
+ >] + + uncloud_pay_models_OrderRecord [label=< + + + + + + + + + + + + + + + + + + + + + + + +
+ + OrderRecord +
+ id + + AutoField +
+ order + + ForeignKey (uuid) +
+ description + + TextField +
+ one_time_price + + DecimalField +
+ recurring_price + + DecimalField +
+ >] + + + + + django_contrib_auth_models_AbstractUser [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + AbstractUser
<AbstractBaseUser,PermissionsMixin> +
+ date_joined + + DateTimeField +
+ email + + EmailField +
+ first_name + + CharField +
+ is_active + + BooleanField +
+ is_staff + + BooleanField +
+ is_superuser + + BooleanField +
+ last_login + + DateTimeField +
+ last_name + + CharField +
+ password + + CharField +
+ username + + CharField +
+ >] + + uncloud_auth_models_User [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + User
<AbstractUser> +
+ id + + AutoField +
+ date_joined + + DateTimeField +
+ email + + EmailField +
+ first_name + + CharField +
+ is_active + + BooleanField +
+ is_staff + + BooleanField +
+ is_superuser + + BooleanField +
+ last_login + + DateTimeField +
+ last_name + + CharField +
+ password + + CharField +
+ username + + CharField +
+ >] + + + + + uncloud_pay_models_Product [label=< + + + + + + + + + + + + + + + +
+ + Product +
+ order + + ForeignKey (uuid) +
+ owner + + ForeignKey (id) +
+ status + + CharField +
+ >] + + uncloud_vm_models_VMHost [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + VMHost +
+ uuid + + UUIDField +
+ hostname + + CharField +
+ physical_cores + + IntegerField +
+ status + + CharField +
+ usable_cores + + IntegerField +
+ usable_ram_in_gb + + FloatField +
+ >] + + uncloud_vm_models_VMProduct [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + VMProduct
<Product> +
+ uuid + + UUIDField +
+ order + + ForeignKey (uuid) +
+ owner + + ForeignKey (id) +
+ vmhost + + ForeignKey (uuid) +
+ cores + + IntegerField +
+ name + + CharField +
+ ram_in_gb + + FloatField +
+ status + + CharField +
+ vmid + + IntegerField +
+ >] + + uncloud_vm_models_VMWithOSProduct [label=< + + + + + + + +
+ + VMWithOSProduct +
+ vmproduct_ptr + + OneToOneField (uuid) +
+ >] + + uncloud_vm_models_VMDiskImageProduct [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + VMDiskImageProduct +
+ uuid + + UUIDField +
+ owner + + ForeignKey (id) +
+ image_source + + CharField +
+ image_source_type + + CharField +
+ import_url + + URLField +
+ is_os_image + + BooleanField +
+ is_public + + BooleanField +
+ name + + CharField +
+ size_in_gb + + FloatField +
+ status + + CharField +
+ storage_class + + CharField +
+ >] + + uncloud_vm_models_VMDiskProduct [label=< + + + + + + + + + + + + + + + + + + + + + + + +
+ + VMDiskProduct +
+ uuid + + UUIDField +
+ image + + ForeignKey (uuid) +
+ owner + + ForeignKey (id) +
+ vm + + ForeignKey (uuid) +
+ size_in_gb + + FloatField +
+ >] + + uncloud_vm_models_VMNetworkCard [label=< + + + + + + + + + + + + + + + + + + + +
+ + VMNetworkCard +
+ id + + AutoField +
+ vm + + ForeignKey (uuid) +
+ ip_address + + GenericIPAddressField +
+ mac_address + + BigIntegerField +
+ >] + + uncloud_vm_models_VMSnapshotProduct [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + VMSnapshotProduct
<Product> +
+ uuid + + UUIDField +
+ order + + ForeignKey (uuid) +
+ owner + + ForeignKey (id) +
+ vm + + ForeignKey (uuid) +
+ gb_hdd + + FloatField +
+ gb_ssd + + FloatField +
+ status + + CharField +
+ >] + + + + + uncloud_pay_models_Product [label=< + + + + + + + + + + + + + + + +
+ + Product +
+ order + + ForeignKey (uuid) +
+ owner + + ForeignKey (id) +
+ status + + CharField +
+ >] + + ungleich_service_models_MatrixServiceProduct [label=< + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + MatrixServiceProduct
<Product> +
+ uuid + + UUIDField +
+ order + + ForeignKey (uuid) +
+ owner + + ForeignKey (id) +
+ vm + + ForeignKey (uuid) +
+ domain + + CharField +
+ status + + CharField +
+ >] + + + + + opennebula_models_VM [label=< + + + + + + + + + + + + + + + +
+ + VM +
+ vmid + + IntegerField +
+ owner + + ForeignKey (id) +
+ data + + JSONField +
+ >] + + + + + // Relations + + django_contrib_admin_models_LogEntry -> uncloud_auth_models_User + [label=" user (logentry)"] [arrowhead=none, arrowtail=dot, dir=both]; + + django_contrib_admin_models_LogEntry -> django_contrib_contenttypes_models_ContentType + [label=" content_type (logentry)"] [arrowhead=none, arrowtail=dot, dir=both]; + + + django_contrib_auth_models_Permission -> django_contrib_contenttypes_models_ContentType + [label=" content_type (permission)"] [arrowhead=none, arrowtail=dot, dir=both]; + + django_contrib_auth_models_Group -> django_contrib_auth_models_Permission + [label=" permissions (group)"] [arrowhead=dot arrowtail=dot, dir=both]; + + + + django_contrib_sessions_models_Session -> django_contrib_sessions_base_session_AbstractBaseSession + [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both]; + + + uncloud_pay_models_StripeCustomer -> uncloud_auth_models_User + [label=" owner (stripecustomer)"] [arrowhead=none, arrowtail=none, dir=both]; + + uncloud_pay_models_Payment -> uncloud_auth_models_User + [label=" owner (payment)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_pay_models_PaymentMethod -> uncloud_auth_models_User + [label=" owner (paymentmethod)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_pay_models_Bill -> uncloud_auth_models_User + [label=" owner (bill)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_pay_models_Order -> uncloud_auth_models_User + [label=" owner (order)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_pay_models_Order -> uncloud_pay_models_Bill + [label=" bill (order)"] [arrowhead=dot arrowtail=dot, dir=both]; + + uncloud_pay_models_OrderRecord -> uncloud_pay_models_Order + [label=" order (orderrecord)"] [arrowhead=none, arrowtail=dot, dir=both]; + + django_contrib_auth_base_user_AbstractBaseUser [label=< + + +
+ AbstractBaseUser +
+ >] + django_contrib_auth_models_AbstractUser -> django_contrib_auth_base_user_AbstractBaseUser + [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both]; + django_contrib_auth_models_PermissionsMixin [label=< + + +
+ PermissionsMixin +
+ >] + django_contrib_auth_models_AbstractUser -> django_contrib_auth_models_PermissionsMixin + [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both]; + + uncloud_auth_models_User -> django_contrib_auth_models_Group + [label=" groups (user)"] [arrowhead=dot arrowtail=dot, dir=both]; + + uncloud_auth_models_User -> django_contrib_auth_models_Permission + [label=" user_permissions (user)"] [arrowhead=dot arrowtail=dot, dir=both]; + + uncloud_auth_models_User -> django_contrib_auth_models_AbstractUser + [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both]; + + + uncloud_pay_models_Product -> uncloud_auth_models_User + [label=" owner (product)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_pay_models_Product -> uncloud_pay_models_Order + [label=" order (product)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_vm_models_VMProduct -> uncloud_vm_models_VMHost + [label=" vmhost (vmproduct)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_vm_models_VMProduct -> uncloud_pay_models_Product + [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both]; + + uncloud_vm_models_VMWithOSProduct -> uncloud_vm_models_VMProduct + [label=" multi-table\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both]; + + uncloud_vm_models_VMDiskImageProduct -> uncloud_auth_models_User + [label=" owner (vmdiskimageproduct)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_vm_models_VMDiskProduct -> uncloud_auth_models_User + [label=" owner (vmdiskproduct)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_vm_models_VMDiskProduct -> uncloud_vm_models_VMProduct + [label=" vm (vmdiskproduct)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_vm_models_VMDiskProduct -> uncloud_vm_models_VMDiskImageProduct + [label=" image (vmdiskproduct)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_vm_models_VMNetworkCard -> uncloud_vm_models_VMProduct + [label=" vm (vmnetworkcard)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_vm_models_VMSnapshotProduct -> uncloud_vm_models_VMProduct + [label=" vm (vmsnapshotproduct)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_vm_models_VMSnapshotProduct -> uncloud_pay_models_Product + [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both]; + + + uncloud_pay_models_Product -> uncloud_auth_models_User + [label=" owner (product)"] [arrowhead=none, arrowtail=dot, dir=both]; + + uncloud_pay_models_Product -> uncloud_pay_models_Order + [label=" order (product)"] [arrowhead=none, arrowtail=dot, dir=both]; + + ungleich_service_models_MatrixServiceProduct -> uncloud_vm_models_VMProduct + [label=" vm (matrixserviceproduct)"] [arrowhead=none, arrowtail=dot, dir=both]; + + ungleich_service_models_MatrixServiceProduct -> uncloud_pay_models_Product + [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both]; + + + opennebula_models_VM -> uncloud_auth_models_User + [label=" owner (vm)"] [arrowhead=none, arrowtail=dot, dir=both]; + + +} diff --git a/uncloud_django_based/uncloud/models.png b/uncloud_django_based/uncloud/models.png new file mode 100644 index 0000000000000000000000000000000000000000..f9d0c2eacf5bf2f4fc30a61ef30b839b4c93f7a0 GIT binary patch literal 408110 zcmc$`by$_#`Yt>X1r-tL1_1%-?oufU2~p|pmTo2}(%p@8cT0D-ba!|68Pm1*S$qAi zea`irzrN{OuDK>Kedid@cRs)$ZMW{LdGKR_4XTkVyae2&D-x zd->0oNUPud|M6|4(q@SNc_l}zTVwh7_4W1Dgr1$~+v!ni%^e+-l`StqR5H>Yq5k~{ z=}41J6?ulwP7A*1u+$sR#ZoEwB81W-6ze}1fC!y~gM)hXY^6J3yPD9q6FpUDNUN&4 zdes((^vFYI_?!3Nk528fuVFrYh_>tCi_-qj1C&;5ACn$EWLmR@{Ex*t+E4q()xfT+ z3sYR_#oreQdlsUS{9nG!!IB5^_xE)b+3rrwJb%05GnOWXJ~K08$@2_+)xqOGx9G@Y zV<=VB%F=K7Q@RMNo^L&iIQ%HC;~j&-o?(&3L<+QE~1yn3gb>}@r|eFU|!v>(n6$45IX$i)h@dDj-o zk%!1RW37E{5nvS{;x!nVu9yQnYqwSBZ?4LAd%=~P%v65=wKt0UI~!Y=5Q#;r>30WI z8~S~b;gp1yc_|R(LK}Tav8u}>V94|8voT|lwzMx6b+6NL<_wcpz|eao$E$hFBfeE@ zX;RXl(0^9eLrBAIP4vMP)KIpR=+V)1XmqF~%fK(r3CsnJ4JLX~wbH`E&_! z<(mCh=5QN3qn>3atY~3jR66?_gwsEbK0xN8`2*uDiYIUaXbITioRoRCqZ#EOnJx5R zSF9O#@!2UU_ycAA>NWPeUP4q7;xEBRiyh69nB#TewBCA1On)0HfhwOjo>XTUb1+Yu zB{%sUKDoo*Zco4URV&=W-j_fh7cZHbk8GhILnnzaIz3j^QKOu>!E`BC^kcqUW~>zZyJ@rT9H(v^4H_Jqb~W1opbIn@EwC`S1bgNxjaql zofYSL1c)6D*O$qTCMFn5oS@un#=^-Dp5NFnJtn4&5!31IbDD$IgHG=*6>Nqzwmad3zOThPjXIIEg^3Ln* zL6PMS>o5EACU$jK*ecuUeduKFpUo$DZu@A$^;Ix)0eQ@Xopo)!T!Z}p1S6rQgxhmZ2rjCwu1%k*mCoDhPw^Zd_La1;yM3p?O(w8F`O^r z+QqY9gc>lIZn2Y)pJK&5fG;7eF}M&i`tlX=*(d7Ho>azIa&>m;w99%I^kmEwzHje0 z+oHY;yrd$JVXLWNbT}P<^@>ulL$%ssHnwx&b1FTic$MNcmKk%gy&npqG-0L&9OBe( z(|7s4pG))5@01@3HeAI&xHNroH*Z%~FcQsFc}=iO_+DI0&$RK2>k}U7%#3{Rz@Q$> z-36{B$l&kN=MmzO#b>96(~qo_@Cu!|L$j)IZq9afw!;fIrz?#VX@?`Y!YPWFq+#|L5a_Cez5-N9kK12^b$p+3HTP3Z^wqlgIov7N2; zf)k8}^cFr(Oi-o<)^qFYmzCVYe}6h!>oRsx%}}sN(MknB0JmU$@dz@b;=7I`k%3=4 z6!B^kpk4Rzkh(v z^J=#KuJp4X(S{>f1>t{E04-f9mQss(sVhgj3JT(zO9OAo?6prfXZtDIBVTY}{crE$ z4UxuqFXl>ladO+i_$0A&cw=zV6SX5kx+KG|^A>%0oRLeyzDJYYKU99XMo3?yq#GnR zE4JeVAV9#GNPquUv_Mo;@%rf`52Mi#wvdSEP_ir%{~q6Cc}o3X(gd~x6W+88{TQBivrv@F-0UJN+dvZFTcPS4Cp! z`j8eB=s?OM`fHv2MjxxpeI5$@RMhWH-nnwJiTSfHM6bU=aGo_lanwr;nCb~`;M9Yn zg+Mny6et^?I@j|ubLP>bHr?hZcWu01-$LM0Eba^9Wp_}xU9@gL!)yGOybPzX)zHsP z!OagAHa_OyN&~E8-Tw7euJIM|ltg{4gCg@1`r30`yN;n3>ji482d@kpT-0*(j3xiDB zPcq7W{a3u3G_Ee9Uq9EGqdjg?LT}^PPqhFJ{l$Piq+;%khxmHT@!M1IOqZNk>t@U#ys~p28 z$|$?(fi;K#^euOGvT4VmMLJkusWV(!*3vR!^7r*KxVGh)2h7C`UXwqoZVP?9yUt6_(Z5I&}tWm6Ba5EqE#Lpk{vrNL` zY(UY}Wot5Dv)B>{sV_yNYaT@6hIp*#uFSw!63QuI_?jA+eLcg zSR%C1S8uFtM{$Tm08yOqum{arQ@bqLbrjEjG|P`_!D%i|U^S%OUZda%2lhCkb-&k3{kR3cnjeC)#o0l8Lay)nIz3j(<_REB& zM^rk)-&o8Ts)yzg=Vu)G6d9Di!*n7s$8?YT(-uK=gDJ+0fTmPYvQUO$&G$+nCy#W3G}W_X_}H}Yld?%mmQTHH&j z)-klYduk`F4xB{H{)cuWQZUhaz!OB21`uVeg1&F6 zXnEtU7CL%~|E*CcCpWu8Ah9%orkn<3VAQNNzKK}jI&+qOo+$1QgHRj~O^cO_snOJ} zu%MmMS<-67oZA@;AflTd$}mHJ;Ckn?`_>&YcDy4Wm>_~($PTV(0Dq+8us`n!1oXo; zRx^qKUZY~ibv=3!70n$QbFf|81lki-P)Z1$(J10i-FyTETC94cXPxZrO$ln6GMQ@e zHF)iwE<6U~jegnY0k!+5#}gAW1@=_b;jjCku@%k9Z{P}Ms$2(Lwni$@!>;Njj`A@g zFAFo@xGICbB$~?|0Kpke6)@dqTiu9}K?_B*xD#w{x~-aaX_}pvhsh>MZfB9dDc~L2 zJl`EH9)CzI<_sKsgm{EzcWm3IeO0QSsvmKZv~x=>O>!Mn3XSgUHgPh!TAz*YcWh{I zCQ}>AQyPhdXKn)K!bHPH#5yWJ%Ec7j(A;qx$z?Pg^RKb85JOoyrUY8-a6_16pIu2a zz#6g`Raf3*`H!yL1_}}`AMln7YfIf%b<+yV%?ctzIR+*|U8uQntfFE$A4;A@{_&4HcSaA@TS_d11ikkq7pP#QPvEjbs^AId0~R5{ckt|KEoGEe^_IvrlK==Hv2WMqU1WNKt$ z{`mF)e3-O_J-DW9Uj;{daQ<@S3u@AM3D5-8iSvw;Or(W{*b_f=V^wAc|# zNrld`+xIU+hGeTS=$oZ8rM{kZ>l!DuX=#G?4+E%nqJhEj8Wzwz7j`yUc}gi{&d!)H zf$ZtB{4E@#MQ-W82)M_!6v7aY1H9^mxDF;q3)odO9T#9(7WGtU!J0#yD~v+Q_g%*t zQ1SaF$XUcBq$x?aIHc4rB0gDd@ttQ&7B304iJ)U*!#8doAVI3CF%TQmi_kf=QpIWs z<&_*A1J4hBo|9WP)NXTVA9_pY6^4BbeNulxlT>&W3)Bsf(gLcZeD%n-xUZ?`SDbsgwwHN%S~dDGqA6{KLXjIn2(+6 zaZRHtYoTHg>g=^vvBE@n%g~EpS=^?41&bY^Im1IG&m-qBXjH|xu28Y zRBl&QS1lQ+!*-cEiW=#S!U`J%kgY|P&L`8(mtyr1-vXGneTLV{SdZ38 zhdLJ$F460iN-*{q{eDbLOgtBRX<_C?dUSHo_b*wu7qm|VRWlwLx^l5K z@fUf9Kvp0noUM@a3{X5cAVJ5rAVY`67z|6j9^;5Cx_w&PBlqVYfu5A*yF!n9F#zhT zNNWd!;*78vNqe4RHncx+*=u8a_R)Nhe(|}^i#;=4psyS67E&Q5Jhe$Q90kWF`3OQH z^Dn`9wndI*={sqtDX3XpAmWE=RtuE%O^RoW*8-%ITQ2@EPYoN^5qo-$kp(KQ4CXyBa6a;Wm~=JsA+8jl2z`&J~s8&1& zGex(tD3oiD%-1yC%Mjv~zchl1q{IaUNz;pxkyiJLM*PEcUMc=+`P!pRi-ec?TT!R6 zP+ObAbxvfqTa$*yd9h&LRP~e6$=(S3kca9Q5!Mpiv&xR}3PAx71BG%5hycM9q8k?- za%a|tOlAopWcvF9xU4S}%NsuG^hufGn@=I}znu9c=CdW%v((EGAnuhx0bH(o*lJ14 zQ{3ilXu6>Ll(1=%Sj~dQXGKRH3V1(Q=&xmV_N;Lfq(=;?|8%4sXt(N*(HI1#6X#6t zTK;AD6ietWmO9Z8>9U|9o)HMdo09wH#P}dY^(#-)>R`O6EG~wFl`@3j8J|?hyFdsa zI&fLCO}A&K>sgA)Pa(iMge)l5TZ+uDT7|#fdhkZckvmxS5lqTNnyE@}YPEGmaWEuf z59s-rPM48?qLD+jijAQ|JTK~n@(CnxAC7Z~3$3V2{=<%_EC()RB zZ{9qZs4-RQ8IN-@P|SsMspF9Y&E~+V{QHO|c1c}@c+Alok#rBq>A;=!(J{iyjdHsQ zJ7#aSd^H&3xpd9l93xF-ZrMnLulh0%mtgjNW&3I z#pcK*ut(bgbj?_q{M)tPJLC|GEQPoF00Zi=RNaDh64<#Ty_cLjjVrcqr5qav#OR^= zO~in_OCNBvQTU+cB@Izw_qk7bFjCZO=x^{`pETU^5OY(6WV!=BHa>tjd3yZzXr>@^ zL6&}beRur8Z&FPz-}=!~C|Z@}@~?7JAZz(AZTZ0`Oom=ZaawXmKj~>8WnQD`xfvfx zb2P>KlwUBFQU7eKNMH-o9)#^QCZ68aDy;56LOyj(W$O%O#H1n-m+m8(t;WAXuD#jYk~b|342Mrq?O; zT%(h?h?o<_FAiH#DGD!RkL6qPcCE&s5++*Bg7YoJtM*^j9ujyQ$HUZ9(pr) zEbt$52rC{8RMg8TKrpHazj@D4`n82llXQ|r#(X+ZU7(jz;}j&}LAJ=!Vgs{H@UQAd>9PwFu$j$Wo^9-jEemEaS4(usAi?tc zVIyGH!(9J%G!~Qx8Wt|x8_@cH7cRx@&H!Nzv>wc(SFN1_&M7-#dwpNq^g6>8@!Wam z9+?##ek$^5?idWuG3Y);pRY+*Yv%I*n2TZ2KwV41^ z;>yINI;1BJ!7p|3WiC%)&Dwl!N8}~YTmw27w&V! z=rotm5@!b1{gQ^f8xR9k1P*=N>_E&vsHm_u8}kHS)}x=L^7zBUBkv`pGJ@*_BdfL1 z(WC3k9tB(IEf32kF>S4K;IKN8eq9eifswljyT|1;pUHrq2!xZQ!X2gsG^c$qcN9sI zknvj>cK_h%GKpQDt~RTsRx$DJmXl%_7Vs=o{HvN*JB?X$zW75u{z!+ZwVRgBkiu<2+B z0c%EaDArvvVB4^xoK%ruxTc8c*VZiBv!-y(*ZrkAUXNn)3p1ufWQHv{iqh{KY4I&O zdew$^xZ`NRECkIXT2JZQkb%yr*GRU~uLm?0rfH!10B~9DZRyruYQpz)NH%pPzns4? z{|T68xGh!x597s^;^Jf>S?s|0=Lj#&6Kg-cmW@RkZ!Xr*yo{!*;;Jceo)+1X&u*Gf zg9qfTcR*j-f_vuySX_mwuGTepxZ1G{vQmVA#nd@?#X4=4o=VeKHU@gD_i7?(@d-fU1=EBY2mwDMD*Fp|vj|C%(i+!FD zJmwRTFI>+TLsE?`*X$Vj{)s_(GcfIkz?7|yKv(6*gB)3o4kjeefDSM$0z4wc9#vn> zwSo`fJBdWO2Nv7*vEsd^$350taFWH|OFdu?g$F~NA43`|G2)*sUdy=IfBfF{?<~Mb z6oXDoL!Pl%ziM6Y3^Gl=Dd@afALs43EbdrA%RPSAg4IjN99Wh1(Dosr#;2e=p!vi3 zI#9xb!@WyWM{Z4bO#e0RfptE>@c@u9gmOp|T}crlHL{MN0l(Qi9|IuAk)5N{+69;- z3t2LzZa0D_cPYSbeXUrAquPOv6lTp$&Qx)O9r=RT$mDPPQ ztaSk8az5x*29_X;O&{9FkLI%L`Sd{MCP{rz9_pxJF}HJ$Ve->2GU~`^aIg~=TG8x_ zW`b8&|9p+nbuui$u(-T@EMxVjhY78q0yZcO+wJj2qGlpQY8dY>`Z8YV(aZyEk3-+E8 z9EU=eJN|L=xuP>nphf2<8z{+C>QK9~gw=Io1eVs!PZD{#d#W4%pZps?G-1^}hr*a& zl?N1~@!}1J!~m!yRkWNVxAhB^Lqf_9yu?xekbt9s)fJh?er@mUbslPljC=d~6`y^k zlY{%-)VQbZrBH5#u2&jE!$JC_{(uH_(qEM)2t`EraDzo)sO-uaZc(eY#&yCjLIzY1 zq=@hJ3pXQO=Dk;i6jI-lf}MyB1R-d~s&9 z2MCpt0*BRhn*w2Bw73^Rb@_yWc4A5Zx+tepFM_|U7!KwuSBf$R{WTXSatFANhKVh> z%^hHGUHa=0*4hoV=f{>zk0pU;?WG(M7C6Yl3<7LoFOus!$91Be-Y8{Ai_ zoz{Q}12nl%xc3s$4cjhviasgyGyZ<$n`fs<`AR2m#{FPIrc*#-)=q6{ReySe z$=puulJGER%+;ZRv6wlKuT)Dnb7vQmsM@QGlEse8A5x%u12Yx*?^mDrf~-Ee|rOV?+ZDPsHjWbpXkWzU!y zGg)O$30SXk7|NY9t5C-O^rl`R>SPMGV66v`&32WBqOYzEI5{9O>OVsYKl!oyAOH|!V|8&o^`+AzWB3WN$|tAVO+ zHj6T^`{&FJDjvz0nI6Is6mVKTWY9SbJr#Ln*5<1nH6IC17${F*J)|fGX8myvLNY~7 z-tqde=dW(}@t5U|x&bSlYE|XgMcq^r(5aJZ%IT70WP~r5v+CrP?N(C9=QQWLUOJXL zx^G-4tX=14DEs+$KTCCbzR+C$6};T!=(0KOGx7hCw)ecpD*X71H-DQ|&q?N`@KbK) z7JXcrifdF{!c%WMNg*N29`#G**v^r(8M@aukJp&yoM1KLJ%a*#Xb8+=0htLPi>$|s z=>w&}fLF1wM=JTjS?%;Hn_ewTEZ6<58@SGl0>${{3l1LZR~C+Yh=b zkExV8eeQPENmrH>9VPLkz~=@=KIOuCQkHyMSV^0N<;@HO5PyH^gnh1)9HI>Cb><1r=Ce7y!IJf}#WSK{Gx|HlBZt2O+=(uq2QNwqOXb1wlXr#p|5Kp>JHJ zRyCHD=RV<>D(M8j*?aGn5ielpF?VBCdg(q6$L929S9wE#x*V^!7ikn`b)XmeN(8SB<=wbu9a@kF$xC>n}KJo;W}!!&YvpAXS@S4jzzf&IV{?E!DKSZG_Q^_ zs6O=r?B`1|{|NXH(T&N)!u5%DARHsd)dWkwa2c>xb*OgC55xmAbGdJmc-G3qYtiIz{u4e3D>VZyl z9uc#zO)vl`3XKE26$plz!~MF3Y4>W185NcN?RnQfC`Agz{yS#YNmgnxEdSf4u)oM5 z>~OMfbc!Fk(NQ~bp_ma93CnJPH-cFPBIAeqdNJCw9$c(hO7Ita7RQz{9{7KXj{@q7nU{et}4S)M98phW# z3O?%=N~RSHy(eheRG%8%qhbZ5K?25*g?Tx;sH3<5cw}`5wS#=1gF7slT(?t#C&g1I zo6aC0ww$1dZpjl4CD;Cs45Ty)h*eZY#ESdHp1uyA$8rOHqD2u+qjYAV71vR$4cJm% z!%agVTH2?^CUcR&^Hz#heI6+8c&I;=Z=GeW=U3zk=r~VQf5qHdePIEaHbC{<%71;2 z^UFQGCMOdh@5OKfGgv?lD5EY8`>w;_VEHGoInQ<`&>`QO-l*^Q@5CM=XGWRpUYFeu zfx_ON`o~Z}7e9K{8Ar(g zJZbIus9etLU6hCYsx8rp@I`%5_r(P~!1m*IWyk)HF)C3E7;{u0KGKR~5TG++gZxy5 zXMi}L-vsNL{P*eVUNjHja4-ihalmHb((%LoMrDt=pW%!op2Kgg)BZk~RGW~WOR*{) zg8)aoOM!!A?&=oU+K}Hl3O~3b_+IbGcxNP*7xzUa0kqfgf5QLt4UksRzt2@e&C_8{ z76=G1+jVj(jT-6%Bm)ibu`e4oObEPC`ke=}FHu2C3LI?MoRNEv2yCO((v9JE)h540 z_U#vJ_PuGiMtQ;Zq2ewpSV#^;w(JXR$=06Tz3iEp%;y1C`qY?l_Yh0+t5@VpI~&%b zl0XEU;PKis-d_$t_Y9JF9hgCGfrN_-q%9q-mCB8Tzat>$6(T;vc@sh^yf?|A)A@pc zfB@LlAW=&Ay~!;g02z#JGzJp+#*CS-?=EcCdSeLAPlM*1k3Ud<$+H)|9w|8|4H^M) zC+0F^b3hDB4tUUELBT9J2H^bf)iJm}zVkXq#W_0GRu#0&SSI;%`oM21AxHmjES-sc zG`=0W?%aGHhRtNt`1o7D4j*Fmhgk|>hm^SPvjfZ88x$UJcaJ|FSAaAqK1i%;=mg!h zQc%E}%|ehE(q|^flP)n32p9r>l+owBcPd&BD9tG`fKUH_44FZ2eK5hMv}+aX`1ApN z0Ai?N)-*BW{LrLr4s(diVE>#Wt4Ib2x`J$9alIPQs!MeI1iUu!-yRVd0PO)_qR;Y! z?RLfg9Nxmnzop|eSPLo^vl-6N&^=zM&CDd%l#l7+6H`-b?>+@d85q1aow`xWr{d(q zK|u*}I{V?_AyaPj8605}5)yj{2PWGcg|&gC0Y8TN&T6am*Ggrt!53H!hdrL4yE0&^ z7i+b_c1$Xf*C%s5p+l9A&WYiOlPyaL-Hjt7Xc-J!!MR8B`p`Hx*3X>*ijseqBOF9z zt8s~YDyMIv?`QF7v5@||!{PJ9ZWoE=(F-ixx2a8hZvNE9fN`aqA1AzC-RVyErUAtr zid>2A2eLl#&ubb(Mw(zoQS=2TEMQt!32g{QOcnp%S@fg#yS$FUK#BnaRdRW20L+d( z?vv|U$!8V$G^az}02#lrb;`@Y22BKP3Y_gV&T~Kxo$HNI`vawb%|a@nJ~=H)0rPL_ zO68XXbeSN!z7d@k^+1NMS4PM2$`w-&j2gCj)=k3*3P?USl{)Lf=B;#mw`aUI~6!W#bD(wkJnn(&Z>Z1 z7dad*Eb}-#xlI z-Hy_}2Eys9m6i7HRQa2B^t-^oKvvVK@Z0Ni{cHCt0WoW9R{27;athKnBFyhjw?_A; zqAu&aybyunW6|#)1Tpd2y1E!vL$u=JVw1^|wki2cDMF=6Gt*nrZGO(sWY_|i(`Q@Y_|yQ%poB0fvE(6TMIeWigQ9Ab=aXioBiQUJEkU?h<2vL`cU7d0);vj&m2slcf22)=!nyi- z+g*4zw`XI(Z0^AxFCM8JP;LOwHYUn25gN_!=|Ky$OMrHr--~Yl`mo1IN_tc2@B0X} zjN-ix|I>dBy%QK1SM(EeXT3pu^s@OxU?~jUq1;9kj4>vl*9PAHmxaq~OTMWtZ$XJ< zspQZ?fI!~khOW8vD>j5K&R#JZ58|ldTt5d14_JCvt(1tZyGA+48)syjZqBtBkqlc( z8+7>I{~As_S2=dH#_0bM53nW)obs_(kAZKz&SkTO0Ran3#>5nYgiXJ&xEP{+y*ZM} z6xa7D_e;QRjU7l^;E4F+xcm7b?Kj->gGSofH|=1MoSgio-38vm%L|>5Fu=>JL-krq zODpdQa>Lyf4G0#L1ok8e2tZ_IWykozy$aj1&o7TwdrAtuP>3_6llc8{SrEWRdLrYu z=N8tBH+_5LbcKMs-1#!lBDL$bpa1j-ioEhNMZ8O5AQ68`Eql) zrg(tC&^zUpe}iV52+S2!LSn?Pk>6P4xk`3kNE);c9D2_Mp z?khK#SZXv49ztkO)$InN$^NHU!gK@abJ*PkAoQ}_B0sM=rA)RtUx37+m70%9`M=`? z4i&b71iMHNaLJAL+wTX}j#m46LQ#_#0U8q`laL;vVBz8U4jHu0&L#v0zo5nPJB7d6 znG$@*94M3XB_EHUpC5RNvDtE&OS`+s2SLy&0Tw2b(%5%jzcNBbbLAV1)z{9UGpRYB zSprp_qM#uDv|Q~Ts|UAeXzS~HB_t4fJtyogDU^t!Cud`e@Ij|!Hkn|A6sS};gXeDk ze6D!s;3SqR(9+&(k_8|%NS^z&Jmx$@2ML;kw&_$iCwX~%PGi#b0?%U2VHMXaBRX)O z!$Rlgg45aU;rkj^}kV;=O2pcABjqsWZE;ng9WB*bSSL6 z{7Wm^$jJvyn`M9(Z&Jv!HZuIU$z2l9PnzM**H9BRgwOy`me%AEuhrDvbdk}Yqv8DwEO2wqIU&LYc3DUV!0XV~# zY!!ra17Ltvdv^I+#Cl&85KIuOEZJU9imtsfCQVI+;QU{r1E^dDY{0!l6ZRVdvkvzr z0jr%IDuCvW^TpzOTZ37`f9{KcrCZIsf^(%3xP>Q6bc@T&S68~i-n1j`ch~#**LbL? zZ_ABFFXr#>^aFZEM#8wP)`BUeiE=(=AAQ=Z>s+pIT6=mlH8H4lvAu)BFRsx!@Q~p$ zFmiwuvZtz=|9`S*>~^n9y|POvP^A8ziXJKO^4y#fT+cX`^&U+bZ{#Yfap27?7c9ep zyEd9YMz5qy)xYW;uo+jX_JE=*x$z26oa$Fc?qP-k!NoRzF+ez;@wYOx-P_T+v2p?u z!)9(THXS-}*aD6|XGb(lCysj8bDgc1nE?(30%@9o<6U8WNy15QqGJSvsm-n zkvFzYt6G0PDSu+EqYBtjU=T9tw8XfJ$BMXD*d5Eu`L_)I(>>_G-kA(D9Lbn1Wng9f9c%pp4^LcD@)<+@1t})=Cyk*L;o8f!xG{@z}6tm9k#9pT}12aA~>HgJ2(R#>;H>7u>?~a!Uq0 zTs6Lbr`$?g`+yd6%*I@u6G(c26gil&MoUt7-p|*91l^x$#2d$xjgaLLE9sox`-YYF z26#jiqTbb>sL3iOW46$)Fv{aaFJd%e;-LKe-Fc$%)Zv`ZG)qfMdFjhQthf~xG5~nA z+r?+rd3=98fA8(*mns@Wba8Q^!HRcyaIgT7F~#B7pOT@*Za)yDG9uXka`x>^mX^+r z3pgJ4aZFZQQR)vQ_6^^a?cAn@#t9HZM~UyvNJ$lM<)Hj`ICfH)g+h09-M0wjzE!+C z1I0nqu)F6#*y&wrB&@>!JZ^-S+XKj`sDM?Ia8CywvmKUPyy^C#qqygXh%Uh-qG+?(O@#iA1jU-%Hqs@d!@A9R)l+DYK` zl_=Di`8p^uvJM+Kbk+F(G6arZ|IU*A&SpF|GsN$DWB&|6kX|VK8c2cVj-bl=yJI!p z_YHtqYA^+{UP{c1OTlx~lJC8cQ+xz^=XXmJ51Udzx+S1{&aC zp?U+swE66>A8~PUr}I}~l+x`(Lm{ACOQ836CIT||ls^q+#F7~;_=U%GA^tW2_oXV09|x* zC>2pkN=h+N$Z@xPd}FqT17d4y>ox~l9@LN-%dOv3ketuCB3fFc*z_umEiGgnXCs-? z+U#-J0KP#L==DZ3`QtD>z{bXgabTn0Ss`(i$Aj+#1>OCx5gwzUP;ztQ0fAL>*4Emp zfR5*Sdnym=T9+_N0O|@!NlD3bF*7rJMM()C%%EORd+m5}pm}w&*%Hm9{ngS^YobWA z_G&8^gH|E9s)`dVeNy*dTZjD_b`NL>WjAqBRl{`HJ#;S@T+m3y2YBXgqp>Vmczu2S z`un@<2D-CM)gMnW!9j($TJK+-)KBXBc*I&NaBP?Y_Thoze3ZBMX~6ihkxw${k#)6K{ zoJ;i(5fNUd&hfP7OxN|kgD*#^oH{sU1|m~!scSqB?kz1YEp6%V4+L=l-O2HEiKx)n z*zQ&zbX;~59Ef~@O7Wf9%I4-JfG=QZ-NG{I!1@86PuO(w%{DBTgYI{`F;Zb==5-__ z_vaoGUK`KngyKNSAYsuqY89{8a!RKI?K2-@t-&5ACM89|#f4is>D<94==p3BR0^$9 z*<(aR#5vdNU6b{(>+^w8ppke2pAJRUpQIOFUtS#-3j%xN;4gT;ADFA_7%Bem-Z(L>n_j+fqkpUV+ZJ*Igg~IgjCJPd9TYH1!ViaJkr$_Ph$2(sE>x~z?)0LFuO|F?6fqG z_;><#vzbnS+*zRRz(ICwi4Z3T=eu*&GA}ulKP<%4(ZDwW*Gh&u;!~ko0*GoCX?HvyD^Ts}j-bf~?+?C1 zLPEmvd@ujA`J{#EP|Bm}3R5z`!~OlAfg|1qiVf6X20&FDeEf!RZHC9+Uh)#A)?SSi z73ejoT6tvM9M`QNYdv%hTH4 zajeK84BO&|ivp(LM_Sw4(a_M|1>mt6jOF1n$4Hu2iKjQWwLNkJ@+34kxDlLuz@LSa zWi&K24*Z=??7_+HpiFXZj%70r?$UWi#H+yFU68^Fc9D)-Ci|0)zWzv!ojKSQ&{@}S z&*!6t%YiYWQm5O&20}%@e}8)L;6WDXViJ+GaIB&$5kzo@%zNoSIR^#c8-I$7=oaXHjtWe&Aw2BvE`#eA7nTNcGh^ zvKrVM=zd_+WC0V$eYckEiBiQ*5arK%nQsX7h>a7Kh;j;_gpzE{Wm;ywg&FGG=f~jp zc{(gkWjzT)49XDJrhNJoNwM681>{*Ejbk}Pr6Q6)zvuE)?$0f5C)Mk>RUmWsm>Fp! zHgX8KNH1;bJVSnVus1=doa^YON8(xUcybE&^Gi8iFAjSkJ>4{ESdaG%W=EbkCd9sc z^I;<*NIp^DK7+J-iVRnM4zEn%rt$SltPii~FI?cy5cnbd;~~mrFo&AqcSB6{iu;PF zE_H?!@Hxq7QmCk@*4}hmID(Un57lMTy1T#NJEpdu9v zeXZ6qHSOz9;E{ceadWX86cI?%)6(*NVnP96(`3EN^@!P#)ph{KoW|@g8&>l<0??@d zl57BynZCRqm6#}HVnWNr#I!SA8AQlqYtS}5F##-&WDY2}18lO~7~AFgY!&b+9bMf` zNrr+Xuye3~c(`HCB+rC;u!sWORirAv?BdR)5)-P-%I1zrDqmNBiS>aO)zJJBfjdvHf9MrvIG2@; zp|8~TaGLuxxMu|e6T5i(f&_F*1h`wT5=KX4SrUmVD~2HN3mnJF(^tLFr()|7@$Y&1 zCVOyM%Zv4OO)zQ#iM5;?>_U20X8LPdzGaO@L54nCxp-H6?i31klwV=ylnzTW(#`3*hg-T_f> zPV`3pdUq1X$=PBzF~$>R=$Gv{-OXUO1fPL$y%lPu#s(po_Iw`>=dX*}J{Rt9<+xoN2bDHgX+NdmoMc}b+a)NmEb&RWChs#L^!AZSVmKZbB{@ z`Z}Rr3T1z`x7muEl{Gv_;4b>-&zG@)VQyBHMA9mL3kpKf?g-33pt$NW13VKZ-X1=D z2pLQjMQLtsrY3|o0OrDOGT{~$h8_|UaKBDXoKnalZ@g?*`F)S}Gx`%|I0&UScHFXs-y#j9Ck(C`HTOA!w8mu6;?9_O4aVBCuhPXVI?s}=(`Z(#4*9Sb-m09|_Nrw}NXnz{G z-D%$%T26?i_qp2T{P_pd9Z(!M;Z;$GNdrwYzx^wx>)w(Xy&Km2Vlw>( zU_~sOiO^e3k{an(%%`&`uj#2Y@7hPfcDh*@Twvu-e%@8WkxxPjp2xgVbDG$r^;FeLYm!4c1@tel7Wjf`aDa6VnHr~RUbF)DM>UV1xYxM=% zYx%sX)EV?z3MQb&O8hojLw`EQ;{i+o0fC#F8zOG0{4Wv>HC0uBa~yq|Iw#Fmt}N)! zw5P^bx0URVj~Q|z+*y)r&>y}X)CR;FMd}rR5j4;cRujXe zB_-3u!YCq?ihk1EA4_I`8ZJ=f(d)li-!BF?zX>qTf4{;7$fu?@M^@Q2IT^FFV^UjN z+c_{mNkXz%j72LXENpSIA^TBIj@U5jk;^Sr0=}M}-o^DbIvN@&79B4o*yQ9dxz&~| zVLcp$VKzLmqQYM+os*mzE0TnDeJ!_J2|jw*XMzv+mHC6!jeD~Dnr)E^ z#d2>tQm^7YCT!__J}YxZJAMl`Z>|DJ_H4xxPnqM@?G*VD5|Y7~jGWZbkJ68`Hgv|z z^XsZ=#%rGLD)c`xRcl(~pu}}wUw<05KQjjjTm6E4vg_z4W*L zF@&`!2dQyCMn0`f{=T;9G%s#trYk+ihiWu8R-QLRjqwPP67>(8`2$mbcqaJY#PqDJ z*Wf4vN=E_c3Ph#G7CA;z1n{(M*;E8_a`N6djvydEN9tXio)htU=jN`7%m54A;CB{k zWMrgdEUTFL9rjyUS=9CZgce}p7O2*U3s_dy4lkz}^FDq0w5Yf^1gpL;Nq`to!T5v( z!_gc}pnjH?+Wf#mf$2$cNlA*$_Sj6Fle|$_YAP8}OKF*z=-AkzBN?%#*EXZrSXiEt z6l9c?UahS{0L~T{7nNL8%M4I~_U@ROi3i~HGOWwn*Y`nVQ3RxjiLN*bqwzTjdsxxexVf{(%#}Q*NC@|JWWOf{4onp0GK7P^!VJ)d>n&^By zx*zKd8a{;6?vdSL&`h%|~1+*T)o>KOWt<-sYTK%HEtqogA$7B+ZF) zaB!n&rYEIJ8x5dGOiR!AN!BzT_++w)QnTf0t7`ILo{}^aLEPGgj4w61toap~6Huc1 zb$0vd@-7VU#1@sm7Ms5_n^PUzxp3Fkm%HbeYWW3fWmHjCrPMQ)iPviLfe|Z;DJ~8U zWU%^&-rE-Q9J!Cbh7T=DhE!TuqF5l_>P4~YSk5gQl?awAlFV}>9ml%EPAyv(meH9t{01lU?Y-T&L#y{EfNJR+?~h@^CbNVg9Hf`ovSbV$d0-gCOv-urvkckCbEkMB6vUUM&};`5AgkLxK2gKBw2 zMK`IpSKD%&aXue?y0>Zz14F@RXR*1t`H4uyWtZU5EnB`^7#|7}^Rp%f7M62TQp>cR ze=~yVuB;JVtg5P_(a5rUUQ=@khlu6SHMg(T=xUZfOEv+2ugb%Rq`kz+fHUg@DuYw~ z+udgnUcIAL)jAqMK|w$!_(`)&u|Xrdgl#R>c(t4=zRf6 zIgj3Xh`Xfh(iL7`|6cx~iN?+c`TcV5_%_ZIbmAj@4w4 zh#EPA?GXjpG+B+|dvLs&Ya&+-+YDBwm*-mI#3fZBG-D9DH@er7jdY8lq%&6#^i_ygD~pc%*-LH-b*wW zE?%THA6#)_qg&bBFL6Yi;D;a$Q&!=xaj8= z6~zPl;ca|88PGVVua)gUfof#b<$BqhZ^vuoJYI5`qy$SkO&^3lM%fmD@PFS9uYntt z9%t@LeFE{0PfF5*&IY3PPASUV+WK^);_jEFL`dhRJjTfsLP72OMn{pkufZ1q4jmdA`UXGI#-@I|R?~j` zWuO$+RqJj`E>176;nmt0#AclAr!6;M6vyH6xh9V$xS{RTm-s`Hth1DHWj>icYOIlq z+31uk9h&8Ho8OxwS$dcO@qJSlf4<}IwrFqCmsk|=PJg#-4Xc2A=G553riCqUpGVPD z9@@C(+ykQ?>Sm_7{hT*;rUlwcTmF^bcHJp6VCLPukwNN{_u8%@@$XmfSzZ~?viMyq zU-#IdUTgm5ZAbPEjhJ4Ko|7_YteW%_ZYh5!m@Ot^*>AHxQ*M8P-gv6U!uXl{zZJhe zD5x!Unkdwe7@O49VAB>5h>O3bzIWfR&GiG{q;xJH8qV&keBCP1Jk9guz?`d&%h!gV zOj{LS|4sCGGHde(vqYUHTYTt;+bhq;(e4oSFRQTDz9l%fRW`*VZAN*kYxn$$;|kjy zjSSY@KHBLN+PPOt>AzLMd`rFW1N?T$k#u)>x;JGTJYZPV&$Dt!bq-u{Icm)u`&}GL z+UbkdFBSzQsN9_oe1mHyp(j zcJwLCx4GZ)b~STc?I*Zfa%}RQ_Ji23QL6qRsGBTz-PzZM#cy|=XdSucFPGu>Yma zy7=T|{o&SZ@}$wNr&6i6tgOBUFuML?c+=R(At10B2l`l>(;iCSuU{};o+>Reo$jlg ze*Ky`Iy#!hurXmD9>bWgLi*RQ|JvaNqX$tQM#vx~B_(;4mFskMbrHl_a~qKWZI_vS z>$V>)0Iz9kCk+Ue4W7Vh;#yd(Y{@f5qtbW|dO=Gt@uL*afKFNL4U}ctA9LA~;|s09)stZfUM$7}3)J|u>u9pmt- zcUY+Ny6p5@S25ixc28TqLyPJB)tCnUxw|Vi7N7h_Fd&+N^3mazHHKB;y!tov3czts z#3&J-5;GCwf26qRL+nHol$bc-bmK7Lv8j|hVd|$HgmRbNh8XnP-#94LXwkO$xu73^ z8X4$i?^+XMA;Eeu;Ih`Rn4&8vPojQ(500$I#Z1?Z2hO8O)-LyB5_6wbHU9Nl!ipCv zfQ5MfejeAS9oM?A6#f3)l{l5eqzBMi$J;#ZvXiAD?%?F$CukD_c2y}U&fcS279F8o zP4~y;PXCIE3*G)Rzjpld2FYi^!POzBpS)L33ohw?cnRqm zo>Fg*M=HvQGQ3yhuBfa;vIwB%nu*DIoH%Al7qQ01#%QhVjQrW-iD|odc%HzXrC*mE zd_*a#4kpz}2Tgf-DLld3kD2PhX;+LMN`=Ff+l^8l--t4#H8lN|YGF}`4s%~qmxzho2P#wE_`I{Rw40HP zS~_nljD{VR+(~=;`CHoY)+J_$nN5}>vbeg#oLXmxmPT>;WK5; zG)dBl@ktMM=OwJWL9cqD$##i%1cK?)-bOENCfD*tYtd=_Psb*1ww-<)r&JeE7hG{7 z`KafS;HtucZz@r(bH$w_W1KPO2M-^-TK411{N!nd4@b=1T*c_3e!Ze>lRUx6gZPnc ztNjCL^gp_~z_-S!-{W4-86M`)>c5!Sy zpmxgK{9{rY?bWIPJn4SdjE&0-f}NFOE2gLIOO2t9|GB7n?OLP7!z+0Xg;A(9yk1OH(m(exqtgMG&r!^HG9vL+nK*rB@RU zkhYn(ZG*IZ_s$(!L=jHzf7!P#H&-&F`;4h6AM6T!991vti+I>r9|*BZ{fm>f3%_vg zIhwme(oNZ4*2?sYU-0IsvF^4^z8g7aPx1pYZH1ym&MkSi%s7?X(%9Md7R83P-TG)E zmHfev!&X*1D{nB<*05D&TwiVbX*w5$z@K&De0H2_x5ndmLANX?hBdcZscua6lXE)r z0!k0%W0N{deR8x%`Xd%#n7;3Cd4jW#-7yV*`d8k`F9BKej`I#3$G8@%i`T!d+eKY8cqO;G-KKS?%}&XR2#Gy?Up|OC%I)lu zZPhgPQN4AEWzo4!Uqf4aav$5C&RIrxt3i+FZ`t>BFAd-6KWT?mgYVZrkRJQ4WmjFX6X9;l;pp4RGOsw1{S7Ci6#wvvy+46jfn=OWo1-ft%^uL+?SnK|XQ?UB;MXH5w+8UAhRu%d7O&_cGdFl0>Mfq`bU#`_h}E!lI%t z0|PyLe3(!t_wL;*R)6BBn}x=7k-Rf)(zm; z2kf2?{uD4RHT>-m9U|qwi%EJCWy5y3HA3ydNZX5N6Wdi&6Z?L(zDnVXyOf!+Pt9zJiE)b{LYt_*|y)s@p52d#KOG_~CO?e&)G?;|5@1$nA> z1|)n-9tO4Y?ii0$%8|5O?6|kD7FElwRY5YeUI5UO3fL8CtKTQdQ4Xv%=cKbexN@=qmrPv z1|~a+**N_ECEnki^>z22Jw{DQT+3I|=hfD3k&~0daj2SVbFGGMvwGdSuKAhqzgDd1 z%i2n!2MyY;U9IAkbBp8Ek`ajU5Ch!J?JxMrPZAR?!@d*a0$IQU6cq$%YBImyd%Y+B zGjzXvBxYcT%4FL8FX|;{U|o*R=at)z7`ugq?L%aS>c4dQlA>Y(;`_I3Z6iz?VyRvl zd~p*(ecQb5-dzS{_ASY13k{^FVk&hr0wZK{BTg$j&2DtJfG@q7nHk83VN1pl++|AB zPvj90#p&zoD<~}dl4PWCd}{i^{rga%OG-=GLD(Uz+S#QkX)_&AD?r}y(6M7Z`Si35~Qm4*%UJXS?0Av&Ch?UVx(>+PMy~~mvWs~_ z5HRnl+6k3HODBWEqbzVW?>2{;Etj&gGV}{H<5MGOJ`Rqak5y`Q z4aFdgq$)$=LLUBedHE9{3erG|Z>H=5$tg%#zJ`FTzLUkYU0&{-S5y?wR(#U7&va(e zgB7EVW9DjO4h~B*G5nRB)N_?WUiVe6fZ2Br+L%2F|#n&%yjA?AkYDQKQhX-00OOAS65fh7F)aOlc}BX^nf$Z z|NgL5uPxm}ceyAHU)$FkZyh@0c#UN~-*o<07CcwRtZFk_aWJ=C@QcHihsD>@Zw;czE_hbMp7!4r@(+dZhh=ufPiQQK*+JNZ=ha zYyDcr?~}`1T^K0os+Ga6o$;;Y+qc&+1+N$fc(O^xT8crZ1=&Gy1`{fVBQrhnc>~el z-<|Q;jlUsGJ~1x=NuIw}t`xBTrMP~F(CwI*11nanICbXC3twN%N&Xe1Zn8M}w!fBgY)n&8QbJE>S^V`1UI-L)2HmG`6J-$&K0ha{E|POZ*h;a>-X z3K^!gw<9Bs!p5)I_&0FaXA1}ll52p8jjkXD2L{Qdc|lT{jf{+#nVH3e?y*w(bP@h$ z7P_?y9=9&UU@r}yUEay|!?I^41|9&ynO<+ZHXON!G5mM`WXeP)-?<(|hOLb9g(a`G zU%I?IrJ4UFkqa`?CH%s2u41;dyq>t`9lz?LD=Tys-Aw1znka8}KY7iLzb8Kj=TOG22Lq|uAGMoI% zuuyuqrL@5I%!qZ(4Qi~0p5au^j zAHCUR=sctHMTnBE#pscIVgEa`ww%MJ&98CJjm0Ue{Z4NV%iMiRTKfF&{wPO8p;7#y zC6rcHh9Sd>htQZZ^SeR27GCMVz(D9xl;hh)Zd z1eoOHa9cIf@7!7Y?_N^nsFk%GDjTM`(XvE$y0FlpmK93peG{MbTZ0zq?>5VHF>SrY zy}(PeO5SVvrAsO$Uj+RkthdNXP7B_0`D$!2KJ=sIqhna=nJbGy?o$(M=51yfmWxdN z2&@tpw%E@y+_we(<4bDgw2koiZsp6Lv$pBBv7X}LIOg(=$qVNBl@CFyR2i8nk-LFx zAP^F948GSizkg+H=Yz1EL7@b;sqm`TR}Qu7H5tXsD=72t12xKOvI>b$JDYJdOrk9z zi1E;Kp`-DgJFA1{ohE2#wJYjm#yLTDfX%&rLD2rz*oc0Lpc4&*!VvjLsD_lqZrSta zKZB|IUMPxG@C)>XdIL^$>g}aly*2E85hO7)mdhz)R#aS2aCX56sz?|i8JrXuOacJa zEoWh2f#HNL5)}wQ(^d{~qVuBlT?1DgyoIv7_rP_?gWV|8dXNa4bDT5tXMZ)s$w3Hw zeZ*ud>LFf#av>@hL?Z(4LZSAP1T2J2#Q2gR(#z6i8sLUR#awjR^YhpZu@> zb~u)VJJdJ-)16K5$2k!6!1+CBKc;uRI_P4AY~X5odOZ}dr>x?BoA#VR4D2{+vbNLc z3Ix}KtmvhVAE!BH+WZ3&>|S2p3sD1Ww(P&vN8OkI`Lk-e#XjAlr|o%nmRG;=k(0Z8 z`FYas$hU9V-QC?Uygqmb*JTqctE`ign2U=`)>s)2x&+PRCr^?+ufHDSNSqkF=+E2VzQTx7wGH+;=GdC+-i9Kl1J%Z?bXw_9qCKpl6UYT2#)xRU5`&s7SWb z{Oax0c3mh`)^;ug(o61;`R@E9kM?7B$Vfrf;^@I_^P>_$YH^|~>AyG(wht@t$pt?% z@5*<&?y3TPPT|XRb!4$Z9ZAH#cFL$?nS4|=9?@V;`D}QDGs&05N$}UP{Zk1O-HA@ zF#9`NJ2wb^UT0TVArgyWxeK)aDFb=o$<_m4zS|=sBN3{)g$_o1A(=30*?(8}Vn)h5 zU*?QL?h_IghIa$%qyquxMr)QYpczaZrqVhkw+!p)$8BUp%@G>Ldw7W^QRv2nrqgtKNF+0g_*eosy7WXf(H3+Yv{Kf zei#z6J7yk}Zr35*(C-l17$#o|<1{P{aulMP_YwT3x$;4AW=dp|ALV8}qUW?%|KLT$ z)X3}&(n#WtlX^gf1;xeZwM(*p*Q>+vmF9kz(5d)E<=$j6Z)PD^9;Eq$M~>(N76Hth zcJMo=-D3Ug>sypugds`SAr&@J`+Q59`8`xk32Iv3%h#_Z(Gc0Gr)mECioRy96A21MDkM2v>D8AYh$CMqeDvK5I{_r{2_-4 zLoVmO-8~6(#|gjvM-0=QpB{DSUtTE%Ug39E`keQbOq-j~E^_b-R1H#3wu%-ZjVTbD(jvo>Hf+884xzF)=Tanh z1!hZNf(^8TXe$6-YSJ4J9f`@(XWY7#n(OL%_wL=GYvsW)$P5b@f90ehn{g^%9<@l< zm|Zx*Eh@|CVXc}$g_^yInfWYCYBHk&v1miIuq!TmavPkm!s6lwz{-{b-#)IY9~>O?`1tXVs;VlUyZ0Kt zNMSc;*(*5Q{3kd1=<(wY_-bEe)Gs$LIPK*zCnFWy`A99CYEQ7yB$LsFINa@zkAFG4^JBQXpcgs9C&i?GE#AOC7^6M)c1 zE`K%nFKZ^r^2O}rWJ|e-cs&hPy0IYf4X!-#jV=em9>#7J?+?PIU`6}*+12$CS?^Ja z1uw?{`{?tzE(?%!?5=f*n*eVTUlDax;oX^a=%PiS1hl<)mlz&-$DawmosVAW_RGTL z1@-a;eM(O5c`sTQmPda?xU3PSSdDWI4kFNKs2(#^jx)et?=>@zvbjvN<5tumz)C%^2{50u^*_Zs}&Q>!R?@rX(E*%aDKYDlavX%LxDbQLd*XgEUuER80T4 zhxi{~Z2S;<@F;}R&W?_|{{HIe2^rS?MT-k_VGWoP4v=&WLI?n{rwB?)$Uj+2lx)zd z13souPLWn0$Dft7%KcM(PS|}c)e``cfQPyb=@nkBtm)^A7ZsoX?-X5uOWQ8Td+63o zmOM8l$)v#(1-DPSp)zMq{Jl0tO5E<>7q{@oS1GNkDgxV{pLenuYJQZQ{Cf{&)ka3f zLX5hqBIJOl)#1C(>(^&de!#Upx3w+Z31-YMA2jd&h|a--~^L&@Uun~kj(3F+Xh1L3S zrb-`f+VlQO)T^q=e`=VR7n07H+?NmYT<5q0$NDw9VG z8Xlgg2>Fzes~CI3=%ak%^^1MXh3K8vZrq^HLlA$r_?nim^Gv8ig@sR;o|o#`IXV)? zf$IJE#@uzr1^aMcW`Jf zQ0tcd-}I*|?XO>@lmhKSeIm1-n7~8LyFE0zo{kPS3oh?T%wh@nJ{=jk6_dlKmh=$G zCtXuhtypISdf(xLCYiMb0i?+s6GbWdlaO0@!Y=j-4gz}DsX!5HL!@CatV?56&7^5$ zeZNIg&MKROd-g_}ww4wv1THdjdnHN{;}>eD zW=NLd>QziS4sS8sNxxIXTOmSLyuLPRc2-EYh`x_^5La-tUft}Rx;m-G8+uBf@0p&R zrAElHh06w(!HWKdls-lULy-}}VCe{0!>z%lBM3$oLGZR;TA0E$r@_?H0|-E#JB0I* zsupsX;KU$_0fx{>@%)FGKSe<9FrDI2v(~2?i%zX)L@+mKr8|k?PFy{_1aEV8r%XW0a00M{&H`aSgg8)N%Z+U5-+mhktEPh4vGOlh*m`ms*QFq_i|?E$rvJ+rR)f0sytmg7h@CCnSs&O_fb8tqxih$=*7*M+K3NF=N9_r){67mb zgVFKVE4Sfwo9ETkw!ya#g&>y&^vtWCLJv+0AaglV5xOm7x<(ZsF()st9)dLTJAv%G zP&{aOwR4bQ7&o9clyPRai-0@smkXlLYybR!2<5`EVd@`zCQX{t%thvc0|VFHMm7>0 z7_o!|t3LUJ#6(1TAcBM_a$9>E8TkXVJ}d7E5VMzW+9}^==hN!ei~|kNb{~QguynXC zqP`&eOZ~_cW(~^ticj-#OiYZ`XG@eg>dSAg^P{WYsAlaFYuk3%@U{jY$AJSLUJ|np zQ2+$ZJ5B|TpH^S)Rvu;+79A)%*&OF3)k!Ax&AdS^FwT0z*wPpK&Rcn^ z_nlOZjg5`gNS7ZbXTpi^IUK)#9{$h?x_}5W$OgR78QHLhU~eb*nPd#~O=G(B{+H$$ za)5dYct|$*s;H`N!lnx7`!F=giNb`SLGzf;gM0VxCEYXuG^DL$T7VWFf%-s}0Kxs| z%7y?0CqwO+1xn+8X-Vg_2kv%1DFx4`@!P%*r|=B~CRnzoO}^awACgf57-5VJ2q2l@ zM5g1*moM@Qv%j>kZWjd}jLyQ+lFUP51+SVu24@=pAl4)85xGv^Ya%1pdA6f$@(>0Z zvCRb5d?gTS=#^mbw7IF_9>7LMtu443Z?pa@h7~lBoSYnXz0!|{S2M#KL=VJO6>ApA zkeliIBR9TnL{dgJSYn-F(CVuHDX}1}f@dSRe+Au0b>$p#S&<9KLVOjKotiqYy@dHR z&_~?7I|$5zfge12v>OBQG*oCKMM+kNPD1T9%bD4a8QmAGX*J5)pywokj{2V8+8U(u z+1=>(I56d5>t!qbVyVQlh-ja;_{4lqD?Yw@xzsY2ix12$N^NC z-Z~S)6B8SsFn+4dQL0U>9tibxneD*0Lw((|@2dI@VkX+dsksIlb_nIc>Vqo{FK~sU zqa!Fb_d?|_sPb^U(=EEzwmDDj1ezynx`Dl4&E5Di)u=e>*SkFx6$+@q+`{6+$bair zz3k6VakAI8P9t$jmnkX^7>BLFW`pm>!>&CQ^;H2~zbm zTlQZdL1rwi>Fn!!gjmzWK7K>bLT+)hw8IPVy_6QExO4=q$>|NSBYc1s)=c zQRp}9$P`ISO9SsE69H#>rY9Jc_3LcDymkv2NzBW;joCDp#Tf%MN@k?@lzinnZ#3V& zO|v-L&!2KARk@blc-@} zynh`&RwEm>$PrUZ% zM}l=gf1`?OTZPpzD%k|}I?DC>7iS=wKsG~QDMFgV?++8UBY&SEJVP||ZA%Mm{zq?i zV3$NEp0C3`4#5Tvmg`1T1Y4K!LEh)N8=P;6}cUH1JkslBxkUK1ji zA;t#<%fl($fS6LPY6H3l3-CM6NGNK5S4X+lIL@p|p=R1GTKrg`d1+JAq2>Lt3K8oy zGpyL5aNr;j@y0Tlh&LxQ8h?lG-M@dVN_6<()jS#;lcWxr;7PDBrx5G4cQ0wVxJZED zpz}){j8TiZj`8}gt=raA``{i^Kk7;&kato^X)EG%(CyC#$AA&S*|}Ym#v_%i?^p6`3W}A6}+dvw)#tW&K%6^d2y}2S_17?gf@dIx5`u zLV5QfC_IYvK3?9Z0KnP5X^~~A{hxs*rQGrq1X-WrlOd?}Sn6#a9vfJ*WD5#}DOQuG z_g#z0G&5)ujBb=$h_^AeIlVGbJOhjNA;aS4OJaPSoV$>)>0fnN^Z-#?p*VlR*ThWq zL$w%}pY{vUupS;LG2`P66P@Q+z%z{rny$CJK&Jr@<~jalS7fNpxtmq{|4o za$Rh!-s&8DQNOnD??Pn6{wQZ^s(fHZt!eiky@ouwBWxY;r;&d}v=vZbxtZCph0#!3 z2;0VEuY%q{E|SsyG`a@JA2fP!S15jok7n#rXQPBUAabmMO=%KYsArA5iTv;FT{RGo1!E+Y<6*VzA1 zq3jkBmXRqI^bRv|`6XjwW~R*-Js|dFDyZg&*TVe#G(^78TX=NPw9`>J7*cG9wfd%{ z0~@OE;a~}vcNAE(>ocvPIJ?fx&f;Zf>U;|e!3Sm~{L8eqI0s9H6(7I7`1g$s_@i9` zveY2s=u&?X!<#GEzfb(wp2jbVW<#t4c=zVEwyyP+Gu5c2pFV#+$rFwD1;N*)rrcD= zt0E5_JopBQ2ZwT^VL|2$LH7v|cYFnH(L#S2%5c1S=b6an2cuXA@&)7+?;lbD@E~3R zx&kB7u__<1xtzr_3agP@IXp0MCmd;SEp>JEM9Uso7;zXqc&F6#xMOHo7gNm$;dbTh z;*TkBZC!jj%a5whI+RA^nG*|Z*Q{aZ=O4z@=)k~*$t6T+%!S*Q zI*ZwqxpqgmsmSiEIcL27ke{F>v(y@252M$$-GbLWj*idpcBfs}%c0-Xp<80VjVB_2 z(v-i$;>K3L7bi7yC1*8w9croP8)Zeg{ z^!1I*l71|YZww*zD%g{QtK9Oe$tDx%>d)-QySu)L$OMxP1aybqb1BrGG0q(}09i%Z zWero1awlts5@xMl`Gxkta>N*Cd=t{_GOGKPg7=+&^`oz^6q+#*uSbHMsSQkxd@A(? zaKp2pAg%Yl1_-%CTq}Hx3CovwgiV?N*WQAt!_8>#=yZ2Rwla&T%?;065K53|MFB5f=A+I~1pyG*`QrS1DOC?$6fOL_Wg~H} z3+GvWQqe_;Zi1c#Ll2VlWvDA8VFLs~gYWPTRvK9>fuWdXs)F=@643YF;x`m7X1)6jV_4W18qBq^fvJ5O+u?A0rw|hF)`a|hU1Z%Jj zXdXZaBE>xz#LH*<9q}&#V8Jrr^PLHk2W|*asK>oJgl#|gYL-8Tkvk{5qq0Vew!;Wl zQFYG%D1~d&cSCYdLDU8OnT;FsAbO=)_N>RX-8{0u ze?ZVNH#Y1(bwWr;hz$47_kQo*#XplBcfUk-<$=F%c~&T>bCsNVg`uUn`IpQ9)f4!9 za4l%3f$o5w>oc3xbm;LaN#q$Z2fMBt>m4rn9>L(yX+S^gSGrrI{3jRW8O#|A2Yf;v z_ne;Y_BTURuLx}nGQ@aKu&ab^K;xW9rJPZzSNL$HnQLSVhjDp-YwghR+ViueFFL!s z-PK~0Ud=s-Zglo@WXI2sxS3r2s7LobAL>RW5CHmJJ9>bKjjlsKv=0xP9Z^Rhzut-7cL(`Z zexT*XkGce0=L~;}{(RQHFYg@XiAgz~6J`=~YB* zh*N=6vvJFolW<09$bKlOq$`4+&xB}X*Ob1sFOPyb{W{sHR zBt2r@$h`SNsld1+9+CTaWZS_;;$@H6t3jp3BT0xA=pHR|v{@z12VBTa3=F4GtnD!~ zK(WxubqR!K!{dC%W)btYC0I&ZMy4Xar}EnlHD6wN_)4_Orew94M2)nKAy%Bqu{%%< zDwV%Mx4Q(cbgk<7MkxmIMph|uh&q;d2G9F$bq@|^1A$wWF!{DTZodAsiV8DKIv%UV zq?UBc2ROw#W@f><;g@s>xI|sDgnj(f!(*J%0QtHdRUVSs!>3Q3hQuCHV?x`;{q!G+ zVuFr$)Z1z%VGb-{`sYhIM0E2Ih(e~~F-nj>rZAz9YV_J2DUU*64yr}{PsjA%9c3GO zhaI0J7oh|TtBRn6lw#NjIbmdx)KpbBpz#yG(3;p)iuAau`Ye3HKC2ID~sBw6*FH zeqxBw>FVhf0AW*3oN@o9E?9CE`qupXcs0VPSFc?Qal@*wa1_L{6ehpJ0MaU$3B-+3 zSb3ae_Ee49@!TG-|FME}gojKqR_h%ZahnTx`tj?;;l&!RYX%0zV9B(-Un94GK`(!U z(}6(d){>?NQ=MLtD-*6qO!hZyK!@h#<;B9Y`a^n-Um?w#FwnQ%EQ^*oR<}l*DZMTboE1f9MM4g}rL}d~8INsbuTqbWk?GClsvDHy zA3Ec?fx=*90 zv-6~)A_HV3X?9}`2F48=HX!)8N>BVJx&$B|ULXU~vH{t!uhFV6;nRRwyw^w<0K9<8 zN25BWf!G%m_X5O1aSXZ5`!lO89kqYjiW|mZCeY?t~ z%>Cz;R|BKo^%6J1M2P%GjsvmAVccSKFd6T?oj=v?Cs9>Ybf)LX&(_#N3R_Lvl=0LF zd(F7#-?JkQIJ)|LclP}*+{baB(*>Ug2a$-_h)!cw8F&4};!F<-?8xC{u5%}*-QmMY zDyw*pD>c3g3Bh_snUCRn7CA6jvU1%vd`<5w%L!w-e}4rHIZ;@zm{(Hr5WW21wSv|7 z_|h60J3zz<|A+j!v&1jm5qVIY9L!rk#6IEeW;o+=&`f1o+E^QnSX<4$r(7(}#j3ea z4rN+=C%e309yra6fu7&$Dq{psg%NsX@p^4^(wjKhBllR~z1e{y64i-Y_;#%8EDa(PTabVR9Y&hh1F1!xhBib2aGx=X zy=L|5nn%ZQfpAaImS15>qwE)jw$jxd#yS0!GDvk-LE6AnE@qh|HFZ%E@ZY zbq$8n(ybz+`En7TcS4Iti=LmKA2Sm6(0p!Gj&tNX;A6sZCkgVQp`i-DBTpa$ z28In{0en6hrTxf{6P_my%nn(e9DdR~=m zMo%fcBo_8jZVpc_)A0`Qx|%3u{*~)@ zoW-)kH0QY#3nxfzh@lW^pLAcG18eFax(48d^P&4rcR;K>FhGFRpwsU<<=gEd^(9(N z_H}_pfH@d_J&2H@6}+Be`;#}9-@*=40kb#NaGw{ zTgP>7M(SuLSOuPW;zE%7Q-$^2A>jVK*-BZ=(3E@7oaT|KnuqHwGG}ShcAkcomKK4R zO}C-VaJ3Wj8h_M&ZZK8Yyu(d)zHxZGxs@rsg+IM9*Hx0_?_nLo_SIi0`X>6XH>x{# z@w=B@;|!uUMf5q2#5zaEJEyVR|F%U%b9RnPnSaHR`|ktgwo$(gfrJaVisX4aD=T{) z3Xx*0hH782Sw)*XLJuDO(yXW`ku~h^A12HUzprh{(!*#knTv3}4dBb;-EgnKSO6 zCTOtR@*ZMO2^6=Zkbn`Lv@VMY%zezrGe&;M_3Q3vPDMpUVouY~K+Sw{yTz4v(vgnI zpp>33MJKJaHfQjRVas9Ec8~xcFP``C_$`nBqg8 zY94zu9SpeqU%wvtS-R!k%t0$zK^`?nZI#iE#_!+BW=^<{KeMrZNfUTF;krP$%hI>U9|s1E8B#s=SI)${r`Bkr+7=gEf+)iIh+1SMx@ zTkmSb9Y8tI`llS=?x_PrVC|}bXD7a@h^rvhe)a|cqX&&Gft7GB0d_U0*2$c7tT8mQ znVm4oE?!rsFloWUojZ9{4u!HEjb|4gO{82$f%cZU_bZCZaZ_Xr`u92ag@uNaR52#Q ziqNYpqZ4RU@6D2Cfe8XPTxZ!u7$s>IT{4~~iwnc9aA;+L2Q31&kmuEIlU~i;zdHj3 zclx8tf&+30pK&KZaL8~tHWP0`$Oq$%WF09P@nv(K*aVBmC8x6m2OAIvxbe= z8{*Tm`7JWFaY8=>D%`qt>nffYkscA-p$ZfsqqZDz(!_z)M6^$#URvNvgQNKMd~Rzl zlYeF>L&d13eNUh*kn|fcPf}n&4@o>pEbhP!wxE9%{QF#Trc*&F0}Gm_Q~IVoWBw@< zFG*9Cg=3E8=Y-Y3ZJI&Oe+Jm{$zc!3vSfB2G~&0*%4K#~<4QvD2m_RVJP<5Fq7OJA z@LAuS9p`qlJH#sKQh{Cf)77xb_b%^R*N71qD3kfMwTa)T8>O zT|V#=@prl-@0FeCGA7`<=!CbNJp4ziO%Kx4FR4bHT%XHSHO_F>Aby@LQL zy;)=EZGRnyjK8Mmly?#kpE-aK&I;M<1CbVacqVlRxC;hQWWaSOAX{BRuA?2NQ9Hh& za@}F&L|yjB;}8#P@al8?+mRLh!<6$N zk{oNb+oRpkZTqC0L<@jeF2iZlqa!>T|mC4&Cws6Se1ZwpP(M}GvoCilpx9Z!x zZrfo_D>3&n*cBx(wBOFN?t~mid~t|W)fBUv`o9-0Zyk%t*1UG&W=jSIM`;Y}~H8N#n1rDKr*gE_%{1^hQ*p|z3 z%ru~N9C1X%*Iq-K>2{2p0?uAsoHN7FrGmFA<8C>OE|re%Ipx9_p3B4A$MK{U=mbiQ z^v6Oo!@QUDZeqU}OG~1|b#)X( zA5o|b0#9r}EEpRgbl^OJit#8#*OW%UuYy(tzPWMh*6>g>&Ox*mJeE+13G{#!B#1H` zDCCM%LQ~4kD0-C|tuH4WDQ;O+Mm~p*9^Hz78j?@g=jkKs0I-T}cYQY;IUg6{Hj?)A z5$i_q35ibwt`3Dn=++M!j5?7Yj)zzfcMscnpT@<-S!5&A46;OnB76_cg~Xq;&9^7u z+k!8_0Wd%oLP%6}`ACaxTRQ}GlsO8;1;#GV^{RkUu#UzIYm=N>&Wm`;(X?pZ05f4P zsRqllaWQ>aWTZg}4f5{V%n`N?Q?M3;jbPlj)tFb#6x!j#KZP!5v9F))0c&fKq z<1)yoZ!_+sO8rqYAMS34{0tOJLf72fYq3b++QnfYG#yAFfRc?@nUOxlPH^lhs8kL> ztH2fM68WI!y$(x{FNh;tI#M$F?s9$EZkaPD<}&kNxFqxkf-pKzG7|cfv>pZQ4AOC1r0+&P6v81yq{^{I`3)+zg4wMi&ME! ztU3Wkh#3T*`uVF@?l||5#R@Q6h=Kr(F8tWg)2B~&*iVy89n?XQc}_m>&sX`M{HdDM(oUggWCY(a2tJ;W-1`2 zjCvc=efas~y@qU+L}HrCQ_U^Z=gVQPEPeqEi{ z8PgdCsnyTJT$dI}8wEb|Kv;l+Se~|U!yl@PmsTJ~1d4+1>x+G5`0U)DG?;R6Z3G=B zZ_wzgXCtuP`DdWRVd5&{)&t*D<$SN;m(4J%A64)X>-tb?il4Ecq~X+tK$1jMQ81&? z$;tgVSCIPp0~$;ppbyCRG{Y6Z+=MPLrqgU+M41eoJ2e)IXQ4ap7ZW4f8?PcXg4~Z) z#|0S~foa=oI;W+j1u?roh&fEOVlOu3syZy}Kd!oPj=_W6nAfl0M~9A0kLrSC6o8p| zvy37Ps*5-8@|kOX3p~9ft&p3k$J={=EjW z6mhi@I4SQ?jgHr^<%E-PY0(Vvg49Y9`M~1F>(pgC+LE{zz9*UVg@e%!0&)XDBNwtc43lxNP+(SfoIfmcslq|c za9zr&@>u|L&H@LbLE5P2Jsm1CF;M^p$#X%#KI-zy&&CZgDS#q13Hs+XG&TRc&*KWU z4vSl9;RwRIA(1pP`G7m$g9jf`*NCsFnQv#m#BMnG_W>0{N%~Fi96c)_?AdgylBtR{uK2rDO|IWFUT@M~Q z#Pw>hoxU(qwKTt=Ahe{`iGl>n9Xvd3kR7p_@8QEY8tE2gd8437fmbf_W5gLM8CLIw zjy)5TkkDcYc&Xmp*n$ZS7KZVGMnSwgN(wy`O5`qhdq_InLXwQM(_JQ+0+7xi`HTgg zF$&2(@|Rwn0=^#3BG%^*rk4dc*W8^n8C+VzgeREob4+~Fs@`nKb#=uijo>}FzqY80 z+x6$xI-B>GE2yZvPVl??_9)-QKP36@GjfUxYpCKA5|;hIPwBzVgd@z**Y}gFkY-T%{wTuL)p$qWDp*q<(GnYlcx>!PpgwTYqaoTLQ zwo@;vWFxQMycwul`0+oA04qDm@CYJ{5!$&f=%#>;(YB0&umyk@D0XyYc@bE_3Jk*{ zU8ShdLjaG5L*^KQVn85zwZY{GfYhB(;eT1809i&!O3 ziH-a#3(%Vfl4f0HL^a0Vw>xEg77YMCcrFP?Kw4}MOhiC45jc|;HRCHYpj zNf!m}>;R%f_w!ygg5*`1jD~y1Q7e8FzV)|pzqTE{@$k{3HDrzfp;HjKMCd5`G=+F- zD091VjlB0aZJ5Iw7Dgc=wddb|ulS*d&k~A1;`!8b=W8W^x2$?=XiJQv26zMz9aA)G zMvo4Kbl_0__7PARenXk@TKA^3!++#aqO?E{szC#17O~=l@2-^MbMWx;_+5g_0qi3a z>ACd|ymja(SOZ(s6W{WOc-^enXPy<|rjQ=J3 zz~|3TpjM-u?c(Nsj1o#J2O9VJtd_){Be|nYhl8aa_DrE<6Dh)Vahy$mzk@m1uJLba z!H)6wa9c16+Lv6{r59B)k&~dH0-z@F7EY6_f3h*9>hq@oY(6K8Vv#oB1%A7V&GrtT z0pTCQa3kOs0lR1Y^|J2Z*TJ~d9avq2EP}`6MH5>JDJ1RUT?86xQfAEjK86Ak^2^{ znfVIWQvlu|0Q5r-4{Si!G|I)bU8cGr^T>u$pc@v9V|_|fj#1M;xB+($FG%VpIad1` zaX8=jc7?!AkWhmXsP_OY$$`NACKFOKGVcyUa_2yw#Oc!gXGho>FuU75{E0cARyPd_ zBH6d29c}Owyl#jn4&QG8Q9G=XH!6-9&H!BCj!I$%5I^+?i2sd|yf;HC0t35*2}NRG z4@gJJ!O@1|_ygWp*@Xp!Hi#CgmSp$|K!kGTFZ!L;aJ}5IPzQXJ*y=E9eu6uY!3T63 zeXdT7!S@I$ZH0Cg?>J@ZPS0`|2n#x(mxZjhCbPq|(zEzyM{2pC3j_JUG9fQ!8w)t( z*q*-vlhoCqxD;Jt!veB~<+O}{2%hg&c-TX6zI6f5Ezu93ha-oQ55sm+Bk=;VASg3? zkwhcQN>Dj*l|MrT4-$0+_`n+uy3$(>{!PH|sKpdP2>~caf`!t*T>kPwpgiFw**WX0 zSKkOs^UbCEJbyb@2eTM*R`;CWy!)RB19=$}SPP;M;m0&g2QT3jbjXN|Q6?b*lFb*e z_v;*8F!@PbeXAzWf?#Z^qR`112ZeH>roAOk?@OU%k0(^j*rL0XhAP znBV^OQ(Jqx&wc^J6Mr9wtR%gyU!J5w#q{Oka@$zV1uzR;5Lw3$H`w3Ep_;=hUO`$q zN>ZZk3dj%6MH)7AIoneFI>~71p!4Q2oeo0Rpz9Cjns67$RTesjL4S zkwz~z$?Lcr$Lq=4ZRlwx8t${NSVN~er-qE5FDmsl;2wXo9A5*iY9ujqUxfjs7*qvt z{T>WbUif(|Sc+iT!OD=GsOyX4ZB&X(OkrLkB z=mqTi_T2}gm2&0s306IY#MEhtCuhUtKO$>}jp$GI<}kGbO@rRNvO0=_eF?Fbk0~j9 zeK#1+n*XkCH0Y*9IDvv;Xstmc)1tJxDs*oloTSm0P~+WT21^hRP2qkJJ?i$vEr z{2TJJTD*$qB031Ba7Z=<=`Ml?@U;;lk3e}mt>{NRjdvTS02PwD3H&aF!s~m)0fw$%UW~ zRQMT6_G`>_05U07RbtN&Fay-6QdDQ}>2>6-WEG&0!nQ*@uvQ@iWs#`GP{Lgi+ zbB^o!UBBmfwq4)v=ktEAb+3Ef>t5o=Vfk>C$A^WRd_8qTa%s+$ch_+ZUsm(uU(}7U zNK3-5P8gTh)}v3JndWL;pTU9Gt!o>IYDPV`TXsAd>=I4C@eV@pO}AG;LoG7-{^|0j z+bh(f8Z(Y8l2KF+44*E?ubvoN{{^28la-!qITZ0YCO)6a%BJF(61ZyByZ5VqBzRZ6 zZ^_u4LL`F1(f?2T+l_`JIP~r%?}ha31CBg-khAhoml4~BFk{2JV*+SxQ@DNP@fGTx6g!%tuFC3IM9* z%oNpi1jjGp&ETs1PX8tG%IU(Qnd@6V-r?KUcv0*%$q@?-KGsoHP-Jzh=pCpQf2LqU z2S+)=Q2|hKGaen&*?9W>F6$URTpj3a20wA&kbx1tetuyguBs8A%gXLQQ5{{BaN5b*h>VBlgX710bn-Kwi3p3q07fOk^fO_u)AmQ zg1OE3>biZOOs`RCZ{a5R1eJ4Xu4nRRkfe;;7fz&`CGC_!K4pq_91N7aN_a#Z)wo^~ z+KYOca)5E%m+VDUnhYKyVM4iI#xILG6B+Et>>~q4NN+QXX_%=0`R5I)YH_C_!`|Qd z@c-IZ{;*j3)VI&$Mo+g>T5i&t*faCdp|PPn!e;cXc;if{Qx#fwxsB`rmRDu`l=Gq3 z8&egC1eHAaZr8o{OZ|bdx+5Lr?HaZHRf7iGi=$UjQPITdN>3g??g6TS7$yK{!(4Ni zx6He3V|2?f>Nmx;@2}9<|Iu&6Pu!eqs7nQ8^7TdC#F;a*bhB?x%s#N)a;m4NEr;~> zd0IcmiO3A6&6%eiwm1iP$+PD(-=;~?wgVa_Rt%3 zYx?MB%{J@W{(3;o|9e2@)0DIZ)dq<`H7A#f20a8+n^41wdhT~`F#CzV7A;~HVy0Za zT$d?Vg>>lohfmmwy4Sa~5rsDQB7JgeBGwcD8@W7;!<~T2A~BzfMzfrGgb{E4a$I(5 z&XY|HH_Lj)dGn5!I_aG~m3ZkAnW$6${!S(Pf}nyvQ3mSA7yoKJQmxb_7CvgUGxz*^ zkH$EBP*lEudfws9uFgjRcG|J;Yg@17*Y?#a$--OpY8G2`-B6eB6ew{m8?vyqYqT`Y zjRxErGVep3eBFbd$v?IbY=xF#ifBBBn=qpFFB`Eb4qHFq@(t>oqNnFiKA-o0QWlSr zxv5=eQBONSp8PVEz1HW0!PrB0=o)~J7L7Z8kAku*Niee*^$Zqc*I;z0aI^Ua{paH4 zl!CsAB=Nf8|JvU>AH>P{JWKzp>+ssDCLJTlP>P4^BlhV=ZMTdb$ToebvD z4`amxJbC|uz?xgS*;gj0J(;eKS)-T$yxDOE-$<--^gkKu5 z-`}A5U zp`NcvpWIokLaL$5GRMWN3XizZ%;+qokVcy}E{5~-E_pYbFd=v1W&B}IqW*Iqo~#F$ z{J-rk`c8_Ul=Q2=7<11R$E5*Te?x}<*IiuZI_`Hz%UWxDqcMx7YKPs_)iC>x@XVdQ z3FxyI6gYH3I#**-_7e@h_7$6(tbm3`Nvry3|C)1LIpqw}(*b#pW^Xlu5Z$+LU+b>MuOHOsr=rwsm+|@g{gNBj<7(A#fohg! z)6}_u?K4-vlnHEpeS7x}2zZZAJ8R!p|8HliyUyY?$B6iS&+Wrfdda9KgMCbfqE(4m zfDT18vLs5Hr%n8s=Q}ok9UJTM@bFM8p*kZiU%~92D!HqFhd+&7#_>>hQaHXSDzYLm zokt%ns<>3hB(T?Kvfrk#u;q}WL`1uveJoD8r$JBwFPcI`Gh}I;p@?AveL*_gIHgPi zY{;xc?Wh^aaXm77RHA)ajLPc?PeNa&&f~}tJ(63`tS&WJ%z->;u0 zemVR9)U={OUO%CJjmWdk4tp34nXYK{@ST7`K6NTWd*ozO5tA4f zlG^WG?6qR?pGC88pd*WE)8s)(*J0fxv3za~#T?s)hWqg8<_%Xei*NsaCWrHI$+L@& z(estwu_i!s<}MhkD*7Y$QP0eR6ZK-)X!!JD;ig7Y22V=zty0qQZwam&_2Jd4XqubRNCWQ6Me=GV!!af_$=FJh)0Ugf0-HFxn{>+AjzAqCGpl1JRcu7our-o z)3;@H^Y$UuG@~^iRCXrZp7Eh^$JpXg77m}wp;Odfd_OijNjNSeHNkZQNA=i>gKU9Z&YAM*jC2@(dP8N*ccwF1qo`9iD;E+JT$EWkD|8`CzyJ={@#~6$qKKk7EvrZQmciVV+?!nIgm}l68B5`CR;IT^*(PlnA9$D%G zoH#Q6gi-+C^b7xEU1^8|5-i|`{V9MV3jkYvg^w&*^5JOr$g)Sf^#(yKjAA%vJG-2i z?~+ylqcxjwli^`1mN0rGYBcrkefsptI05Hzms(Q62hPA$MTq)+oE+&EGDFJOj3_yo zxz)&XUhY0Jric=RHc_BSVmA$9a9+kt>IllAZQHl&Kbi;|&%3YNqOn6O#%buNG)$q0 zT5!LL`cR0u1SK;j8<5(UkXGQPfy7PQ9X8@7= zr*b?X`EVzgM-vR-?Cd;z`tVCpR3Vuha#pJr;`f1oragpQuoRna2}V;Ii*%DO0)grV zW@bL+@#I$Djv3vwzk>!Q8iF08Ec=p#YYv(6^#nf9s{s)@1KTIL@Mg zwX{=ufX~pyh{@X5%H*1(PhDxHN+ufC&+pN!!NJLkKkT@jxD;g#XI1*q8m~DGB=po5 z0V`m944S?`JAIV^JLBoOo}ih5OFjiqup_&%_fS~CrNj60+FCYtaGvofBFPvq(?wTz z1vTy4Zmv}SOla91!zxO6Q}3%wf2qn9m&>aSHvN+5)+RJ38TE$*Y(03{E>aNAV=KWX zOOz?1g1Mv%Q3r`Q0URh46MN~bw~0!+`a@s(!AEZHVFF#3abSiOXdE29`TE|lymGN1 z!Bm>G{rnDohQEe7UjQGKc(stL8^ujSO-DKV`!)Jeg1nl7mFH#?inZsL{U4@iQa0 zTfSvT79l#L{*%`CK;0u8FUJy9-K*Ar>Gz?R*J6-QZ!yY)c+NCv$oGU25%uYtSR?cK z#fK-{Ia>3*hbFz)xg?pWMdaOAOz|kaAfeL|FnGg7kdkBL>An}H4fS*@Frn2XVcNqI zo-I6i!?<{tkyK`N&iU7va3^KIHKRo%8o3haMKBnD4(j$M6{JG60;Jl9{aqgxkGj;4 z?zqX)iub9xxjRv1&-?k^8zE~ZVmU=WNEJrreJ;Npfbs;(in-UG( zO(98FrM3Kdc9GYzm)DQZF%lm+s(pvo^`~6RTW;23oOWJY%ksqQ{8m}#E))N=AFhRR zHs~7Ugf}f728^1Tf+NSD{YM8DEc?$5NqLiU=3l&UApxU&c<$++mdtc3s@(GWD?htM zPxpFwf2QQ*>}c!%Jq+!^Ct4;6h{%Mw=OPI`2RDOR^9s=cK&0S67&iMUqhzUhd3(Qp zU7~oMwbvF3qx6VFU+qafS@>78RQ(@iC#hW=HA9iy4Y^0Fs26(h#20n}+`) zA#I%@yJQ&!C7jMU}%ep&bYJ6Li9 zy$4AKs4D33vb|wFW&;wz&{Pg*YK4kLS&C)Q>E&Cu)=|g^O$O>yCE$uOlM)*}(Sx`z zgu7n8U*};JZv(A9ALz$Nlm4fE9+f9$__Y%%&g8|2zpu3^K?k}G-Juu&QGNe4KG|cq zdSctO-%Zv%Hcmh0wYQk*sGYpFS=5n>KIH}+zI6Ir&@*;CDLI4bE?4#`f2hetRR+*w zydwOiNQuZWRXKk?yi-olrNL|0We~i0O&$iq2uxJY-X+#4KOz z-Mg-7@9L$q2R|k@JMSqEdsI{YU~A;LCJ$Kg@R#?XBiFm`d7AUhquomFMOu#4F3iNx zK03j((cN${78@e503VUBtUcq^nb!X4^C)b&bOO#M1Q1H0V+W7cfNZlS&71L64{}YX&U;gs{`MuZJ zzENd1wzkvB-*U+)icT<8F!A#2j|*>YXgprc?A0_9t+5}6dM*FeDE!xGQZ2yynGDic zh*hm{ID~J6xKiu~95GU7@NvlK(Q4p)>4+LmJh!nVsrnBzPu(Y;OM$sl@%=$&6|#y+ z)J*LeZ_U?$rG!a>X0J?88{UK5 zXzf3x#Vn)tz0oMy(up9Yy`(G-`uR-6F5yFegXujs^Nm(7uKDSQM$UrkWj%IcD{u!^ z*xF9MmNV(#Q_LI0e|Z0%DaGlLUwnBj#*2z3_xOl{<+W4ts_gE!%E#Mh79<=ryEKAY z5OF~Pf^Z}(xM*LL+v;IGdvG@!)8>gQx@SysF3?UGWI-%=i>N@{k$Jfi`Jr2TEcrB< zADz;mt#3+YUU7Ja@qbzX|11>WnV?kUiA+fC;f6V|BZ-Z2aqn||t1IRzOlrQ@@}8*T z7OkJ{>A^gw8J0%kYzrv`YdOd@NHdbMeIZ|!)Ut5bU*VqS9)DvziVafc%9oeUc+1Ww z&_)@MmC8Av1`NDr_?J~VHbn+Ull>Qd>D9f1k%gW9;gSsrJ3QNmnA^>5YIi&`zn1?p zf|Gj?GH0Wy-PN($47bo?2-FR=U3zCln^Sh~I11CTKrn>>_zVd=m!GNqViX zefu--;X~~J9tblLf27DXSHFvk-lpv{U1?=w^Abq%aOW}HPO1A9Od||VADxhPW3D1J z()Uk;ll<)4BP##*3Wk7B72ZruO)J7)ub83u-rOm$6p}!3HQL(j@A^ zfRBdX`DK#%l@N%G07^;v0MZJ;6+;5%%^1zt-?zz!bN`$6?p>n%EirLDb*eW$Y&S?K zk|Q>j$2=molHTf5{0mr7RSnfW5>fEwQ@*)07HOfxaaTlT3GH<0=LS*NrA!0JlW zUFexPHq(2I8u{ZuOiW=;=gN{i$9Jb-2R;6&*h%S!znnE5W}sP-(ZG<0$2s|#1xH0X zt-p9Ay<4F3QLbE}&%_w}w@o$=x^$JkAl*|{yorq`n7w+Flf4gVvX~h>&dseWsTcY5 zb)cqKn%04v(9GxmXzf7IwyqtnlZ(-uwk4KjMC+duz z;th_H&5lq_duAm41ZJn*@rjmC2ya)|HIc`hjs6Qj-G5Qh#?*O8(Nd61)BF%~5hh5E zEKCA8_`bwR5v|v;vqUA8*i}j^rLsEy#!K&TeT3)KeImUKy9M`*KF6NANuTmVuadIS_1OzXh$F>2;Io04I%e^xc&@PPV)AH zF!)wk(sb$v{~r{n3S5tDI3-YVJ-;fuxXgd^pff;rY{h`X4hPrY)YC8vpG8$jRkezr zDy>^6XPL^k=8*7&j$tx%4R%f@%DF6K4vlFPKz5NaVzD1OeYy`$EmM+Sa)3KC$G49` zaK7hfY9}l?ZWS1qg;k46h8scIKydktmxK(?OpMn*)Ci_P{ckIF45P$#%d{C@yIIZu{W z`dy#Wy{IN{k_!gOaL~zgeZI@L0AO}fJU2=&UWgD6^7A)$(rh+KNh(;zT~ zP-O=XLteam?ON>PGZV7q-vvC-VA6p6T256T@W^!s1#ml9th|lUvu4e@cI#HR>@u~s z)jKaG+Cubc{c~)BZd4j$(=Dk|(zf-SvJr18qSla+>;5vLBd#8{h(Fe+tYV|9YbNg4 zv7_{^jba~F+*c|SlK~}+h4ciTnN?HC8dDI|F*1rbtJa&78*-uP+b@v24(}RTSg*EJ zC1OSQ03j`>eW2U5t13{v>+xG~KX#F3$}&)?2!Pa*{e(=ymYS`GM$nvnjQixyShv)> z6cO7?3c|N+(Wa#oOl2_y_+zrp!U>JPt$2h0NX*eTixq@o$)Jo6+(B7>J(A)f#h56)8H+^^D7z)7 zJibSL@034fe=Bf~xW!Vq5958EpwZf*RjUFFI#SS6hi%=un!M`H2bjT9Fk0+)80}>i zi7#2R(vOEYh2$ecJHVvX@TB`ePNmg1GEKsFL@kTAvWWS|Xjtuyi0Mu2ai2hElX-iVa2aZ~K{^=+El@YrMD)2F`7>&vJA zgGiz9;wre+69hpr!QdA<#t10L#k13QH%mt|&;UQD|lJ&vb%m<8+3wVB1W z!hUg9WM%S?et6h_uK=XYBW!ahO{q{%e%o?=YCo!61IJ_E@6K&zcb&0XMM{*iM`UL& z1s9V)Q3SA^Kh&b(J=9P7trOjyzTC=^+H2M>duVv20lu3fFYmo{nAq$*am?aLQ00;sJN3ZT?l@t(No>QvEQ z-EL;Sl|)AQlfDla+m8+Lj3x%jfDyNn^`2qI+F_Y0O`EQ!S` zjTk@)5J-|qy?fX0-8O!RWdh1d=piMP{Sf)9R$Z;}_r?dOR0t@5YXkHVh++<>m~J@~Z=p!RFxP^y3dE@IV^sO(!C zFKWn--zr}dnq@ifjR9TKAvSah{1uz06uXWWj~fuR;ly?kn1!z+{X|liy52u<#QEg3 zCaCQC(9~^I2`+)2faPjP~ViHRi?>^mM_wc?&xrL`|$fjZ-yLoqc?bDwr6@ zy1Cf79a~(LsVGwYid)CeM7WY=4cR*My{vxEt7R$5jVuFw~V%@EgZ%x zE^)t=i-4`&=IcH1nj;&S+Sgj)j7WD6aqdNic7AR!rE z6;DcvE|`R`o2zf$xUrImMij@uY&k)#@S;DFz3s%o1UD$YTT5@S-p$R!qx`zJl{cYB z1s$)xtx*FAf-60<@0C56r-6_r`}Pb?t;Y@`8XCQHP08{x_MI# zvtPpTONfLtd`vcO*-{_Y=)dd&ZLUzDy1dFi8V$f55skn%sm1*;snYLUX2M^ z8mUprgsyM8fI*-lk9vZhGct8>aeS{UwSZz@4b`Hy&uiw zXa|S73K{kU>Kh#Qv@~))$GQuAj|Ep&p_@WAV7(a)GQtAKW7 z$%p+=B0mxZ;V`j~)`ZXWJNEuISjb z#4XPnSQdHUfPe~_1cUHV`t-%F(V+G??O?~tjtGNtY+5E$fzlZ z8kyKiH6{9YtMIJ%{WwwAUI*8ftMFp*!zx%sHDX%{h|1oYJQ;N0z_hTOX&xStG`>3h z8EmDC9d`;v2%diR5Z}mUfbeziz%76a3v+_(!Ev-(TkMP-Q zZQ|QPfhWm&7Hm;fn3?PleL>ufcxBHI%kq%d?xFMRB8;(Ey!dZGK_DKbbzsS_ zz?P_MYj^fCjA<8`f?bL9nve@ut(=Q{16zYu086&m8|&J4WOy-!+jauNUb@`N+6M=R z*y433t)*pU?t2d$=nN~=ZSTE3R!?e!sCKs%^woG*JxE$GZ3R3_>eMTI&Ckr7N79u* z>$=g_=F{Sxpkv8PsQc1ja|s^2M2X@GkQ;4C*iKUki!DHVr`YD^QNsnt8+JOi)er z@MurrtC+_m4sWbJW}6yay6wb0SSo1S?&C)j_z?wI>uIU$U%+@#g^3{}oa(U3@oCqs z+sJoT7S=J$Huhr*8RM!SE_NF)Trj3;4y4r3rVN!o`U-8SG1T=>Fn2VW0t}}lAG<*V z2K))xBp%5&{dX^roB*n=xL!PS#*7#Ac{^peq|<54uEFe);;H}Fw6(s0F&qOo#5sGP zmC!-N)N&+UNQ<9F`udH8WrPS(nUsa9b}$|!OK-qQxJ{c@@x(hgIQ;k74}Z*${`|da zq3@ib$KSu7gSlu?~XMzM7$Un(6Yk1Scst zlNx02@2^bqzQ5>z-2oRJ4j{bdu+z!TL)W%gE=cmiGWMb~jqrRzn^vuyUo6no^;!1| z^;*A&CAo9wj-GukFg518#{Ovf+J2|>qH$sBFWgY0jubCfuUx4o#mk*L0lRnWfcQGe z8`!jSXDAsxl0aiWti2!w%HyFtPi%_n5V`U8Xn|{2U98JPn9V2H*JnNeY}MpA<&ovFUZla_2;%bZ+P%B5SnJdV z#XcUB*hzyo>s*5Gm@%~;3uDL@irRGo_)63o77~r>C^&S2i4`J?k7d^r+amJfe5pst{Tn>=Gi zL%PGTU$w{oC8z@q%MwO_+h}1?_5_AjMh2iA@{z-(Q+*1?!SY?8kaT((k(dwrDnG%* zGBtND9}DS0x7fJEq~42ueDgxScrGPL>lwThzbu$KKXWVp`45P;s{qPRj0i(xHOtS> zmuXX>$I8melq&7q-F5FjcpxJ_>`ADrV8VeQw#RcPMF3Oh!#ItY8U|pA(Gnuw4Cu%t zP*_An5b^9XPW~uIu{zG+D+-e24bP8O){Sa9xfVHxX&{bh#g7ktx@X}4{|hwhqk!a= z&$C*!YIWz{y_L*Rkyq<8o(;S`+(@9b13t(!ll9L}hh;ai;d}(AY!h z(uU={D=JEP`0z|%GyQ{{5l!!qV8F6&$By;5l+xH?wxB0-Db#CBt{w{9tPvIkr_);C z1ML9X=S_In5yl#J+qZ7qsMtQDK4neZenO_-ng1`is37nxo7Apc+d6p3Cq@1G^?^>^ zj+Z2KrecMPGh-zLPf5J)C@;eA-@RM)!XyMMYQEwtKY37~5rIU|<${!CK2hP)v_T1^ zG>6fhAtzXe0A-LM z1S@$$YFK5l1;tA&0N?=>F9ze+66_dzXrjfnY|&@7~q`KrdL++&qFo58NMtK8sE z#Alw$p{lTcSjZR1Hzabk3(t4-v3b|e!s9k|5M}o&o|v^x5Je->kd=yn{Rh>w0x}x- zHG+Uv1TbuIWq53h_l3WBHiMM3G^H-1)Es+{|4$3BYY0UsPcM?F1B3ArGz8k6nrfym z+rwF*BJDx4?75V`IohijERp^Lcn%p#W7^=f#k&@sg2SPZWt1sMh8!4Ku+M03u#n{R zA43|Z^otY;Q=u7SN-Y?+eft^|p8HU-*blSs52KQXlIb|s`$Ev&Sq!I=0j56T{bkzw z=ZSwZsJpAz3ag-zymA6@27z46IA0YO0%Kxtz9>`c7y-$h&*K4vR-Uzl$Y9M;^? zcNWt(Lih!H*q7DWffN>ExrHjQTe^I0=C#&P61dScRYK)c)MIIe&y zC+|3;$O6_#j!PKhLYcopN(c5cUCYeeWZYR@OP6wvIBmgu=dP)imE_6vr@8!BhLfKr z`Av8iOc7um^b1oN44p9HPX&Ee{rMUXDoD(-<6SsKY?5kH&k&G5m6n>(V(s0h&s{|9 zXjrgjjSJ5v9Z1D2&XBDKE>VNoYQkNKUT+b7he>dDC#*+yXQdM~<{D+8h)kzWpPuYE zdCSh7s_pBMM0(ORSfi1h3-wnQ%}?BvjY{l+3gXI(muz@=xNl{n_G+4%^%&Hk0W&-* zkWZcYs{v+V@cNdOs-g47Eii3_k0AI{Au^W`-G)74M`cSP(1~mXRJf=p1K=iy-D+pw zlYaYEqF|`(2B6C3$Za;(2unj~TSqZ3TG?PMMhO8sa#klVzIFY2Dlnc!qGPS&Vnu4p ztbNG+o>t36^#O+(G%6!v_wIExG$Z|MettI`YC&!NtH_Raf4=rqijiZqBa9We0xQls z_|rKC-dZtD6POexR; zuTM;-BcTHu*^Xl$gke@7ZGbj6rnpusxO*SyyijQR_D481=N z8izdx;CFtxY3I$K+tz%}8&EU;tGR{e3@e=gNyFBA&z$)?R9n*uO@s`Sy2Gww^4!vg zrray9*$)oMP{Fx#=O)9XNnoShbOJF6s=n~avvlq2p+{kL^H7qwKr2KH+jj1>fgW{3 zv8|x#9A;c!T|bs$@+BiXuw<_oH4AzAH^ysLmivVfAowM9)DE*hJmKuI`|80~b&mgf ze9BZl^L!dA{{=}3N|t^hcNbsHw_T1hB(hLm8{o8}`wymY-dZTj^6 zA{gEnqQZ|_37nDDeBcyip+_kRHRFy_Lo;#Vl14EYJf`k%zGWkCbC~n!A<%&WpkN^( znNMYX9A<7_7Y-XcG;Nf&8wiAv%+1J*CK*4ZXHRwgO`@IR?aJqa9tz>v4kNQ~BK$}E z-M$`Zn0{1c>SB<*=zWJ>4SP&vy#p)6Igi`FQR&+C>&s~;A!Ue+i>ta@(QA8SvZH>` zR0$*-euqD+VZ&B;=%%Mf+jCA{lFo!Pb5a>EOpcA`V5hMSN8D8)RZk`6dkM8 zqPA%?3)8*(@S!Y_08=!C@uG3=Dxc2U%8Jq>Vh3@-jeeqRf1v+FPW<^i$d(t$XA@7J z=g*&Obc*TAeFLP_5dkP20a-@~wWLAzteKp@VnpZ5Bqw@*&+JFU?V?Sj>s5~~uHrY6 zH{I37>_qw7dksKEm!Y6S$y&bi{UH;?T4$bAWHYntp-Vx=m`>&IbZJO*VDH|&MRCej zOA$prPVfjN$#ldlqpL8NaQwVdtxxo@1>fUR(v%FN#N0P*i85E;67qr)2h2ejFrG|2 zkom_*5}ntah~~|j)%|tj10}OV;mYPDD6QI}?b0QopG8xVQk<1VD!g<5ejrDNBJeol zZ7^n*1!W$2%x?p2f2J!}6HhB<{udOedo}ZftJBa>0*?R?Bd>iqw9E=-HNUtxR2=fi zb~61=l9a+#=D9ByXy~hhnHa?7^10(ivgGIY{tQs)dl^RAmm^hc*q%~V&r8#O0fh8W zRF(t153QUe-a6;lT)*>}ZX7T%Vz)c$+(!z%uq*#d1oBvkOoqv))qJje{(ky% zf4U)%d8?sWqUdMJY6=vu8;Cku)n|gOjOEx8%!G6hy7U*jY@h{6r=$?AS3$?T#1RPFed0*EMecXnpuo$?bRv90%U9nhS=hbfvQDm;^ZB`7n=kE=}L* z5iJ$JQ}YT4)A6T94@Ixgm|pf33NcXJMBQ8%R=LhX6G~eL0*4-ntW~t0y3}IKH<+9g zjOZhUY$Vn7s8Q>=IMz?;?XnbE!HRk!&L(xU6bLwi%yJYHh*wkx4H^_TXiaTw4qx$Ko(&R12`nTd*T{nI`CZ(GNYMu@&e~YS?B_+x#Y8H*L9}o2b)(Bj zb6;=#Yp~eK$*BcG8d>^q#3iv6^ZBD4UOFD}uw#e;O9bkqP8JR8FwQCAj?RranRGr% zUW|M+zai$!aPS6vN*N}d@9iTxd$pFJ1a>&}+47$=j3kt$fvdDl`m4u}=vK>Z`7HeGcQPZwc*b3d#|RqW2d{KUac#wuF-ivZj$AQvxUA-?y<#hV^H`GPJ?^o*S9o94Fek>GojrZJEXtmk=%dBN zLJRnR{>Y>;Kzr*fZC!&l!e7GQ?lJKTAs81BHR#_MEH-rBS3p6vg3D({9Z6qJmT1UF zg->ZeV8BlD;Tj$;Lr=9mip?ENWhxArfdJg%K~xA2i7 zlcG|Qp-cPioeL)M$*}d2fg|p*>=P7Ku2d8>+~VG}k21o$D}+Za#1)5xD7*zm>4f`T zC9Ty6^WU+|1f}X?|0JqF{y03hcpX7K(ee;8a`~!;M?{ zr9iArX7vFr0PETyFyy}=Ag2^ps{x`bl86a^)A2fgwLaKVdYQn}bgJaZbLOn20i%o4 z@1_wJW!{t-vbaiCtpPoUSy%)T1VV0cNc#Er_X|Uh;!f=>ebh+v)zYq(4-6a%fC?t_ z!nE#fk2Z_;Ng&cvdIAz=VRG5nYxizO8GgAfxiDRrn<2?YuQ#`_Pzn5vQ-V8m3!h7A z7PNl-9~4O4X8wVh2a8JN=fMO}X19SmK6Dp_Rg| zaN`Lh32Kj@P;sXUKViG)3P%=@oa!FbYX(?B0D|k@HNi!L{sU3S&05cl zTwDC$aC6MT1Hi-jBkDlbk7ZGXnaBbmc_HjpBGi!v1XG5@w8C5e0xrh}gt#x!h zqtW7H6rD`mZnmbmWs!rZf#eKv4Afe*Xb58ABs3B=1sr1_{0u~kNx`k&=nu3-jD^B+ z9i<$V;li@bz1U;kn7Nr0bOyELyh`wYhv1&MUIXkKMNWu9(|*|XgJwZ0+b}N1_DM#0 zk!;k(?}$wxIzYE=>n%EQY+J8dwW{?R z%R0}fG!or|z&8owO&ehyTTk{Eg%1E#>irk6Mm3EbNENw~pe_6H?1xqD>$~4jpf2*Q zbU5(Q%(5`bq=p!6t8p9kMDxy@-S8lxWNsuq`&wndnxf6}B-sj?Kk-eO9W~c#bSbbU z?15cN{ba-O98W@m9gJCT>gnxpgb-0z#*))jtgq$f{z+wo>16DEJ4$rZ-=(@TDj%Ai zQ2lZH?}%-*sQN{0$NRB<*RSmZEH{}bd;#E+WDe`b1l}O@AY5`&E>hVaP$~=#{=xms zAXgzV?ZQLU73&`gQUkBX_Vf6w(!d2f%|j?%TlHCuQ2{qOo!Zxq^OVlsO{9fDg)|1VFqt^zFDi3W3b z{OToaaLhV*h1*6USEl@u&?J39ssiH49gLj;o)hKs)--O}s~)_M=)b7>u03jMzr^#v z>UIW*dYekkdWLBC%U)56^fVB+5dd3tud06^JbWmlA5z{4v(&9y z6T~balvJ{L=Bp{X_J7lkz5*Va&&&tLVa+J{Hrhe$pS1A{B_wg{;dbpvM>lH zEb)@RH3GY*5!7SWmp(x?CJhd$hG?+P!0`ueUk<6z5a?j+hjp}{N!XC+M}sFHbbuC^ z7aTieSDO{jI|ZhY$VHtamJMVAp6T!xw0}VZLCo6nS(={r zAtMRuk~xLL!XzYxG|7gtCVX|O019I!RG_vAwW4#mcj>F?#xGQ`r3i^x*^y#>)h z?puH_g!w2Tu}JI;Hmt$@geM}rx_w56HFQcQV?QoaRAuGmF#@PO6u$$P+QHSM{{j20 zR#T!Zxp?2sI&eg1b1YRz*uhweBG<1=Bi)=nDth%*sPE!BXvD3hk0L7ou&y(w9j&IJ zq3acGmZ<-@ZUDnZ@D`LAD(n_S&@Zb;5_yDbiHQI9GsWazn|W5*aY?EA?>!g}E( zEI!G+ge$_9069`2Zr|xy=X2x6CodZ$26+fLMU4yd+puwC3qDTF`e7P$E%F-VO(#@@ z*jBwC7oHUy^JGIpSflkZ*4)PE1(Z*9kSB{aN`|%W&yTHVh%QbPu^M&=4<&kiSsNW49q$v*bI*om|8&UJ z^q6GOUq};B6#}YNuLnY2lN6|S`aa-xcj93Z{L+TPJ zBl97MYh(vFXxq@Mw|zSR$|{6obc|w8XH7sC?q*n>qdLiX4^pTp;T?I}N7( z9(%Ai1*cSDG3sE_@t{F0dq!y?NzE!$J-naTbE{CAqsw3W~^i$Vo%ZU~?At^|3O zJXxF(CpL-l4Bdalx1uLojG&b#qd+MA+q1_0^)uI7C}`!lNwcNqtlnI4u}4UkO4pdT zvh}Q%*<>J?bTV0P5{e4T*N^R{z(JQbF{uq4sx_z=$lL`yj>gg$B2S$%XcxyPa$EzuP5B2I`f zWpIagZ{J2jCV`$NM*he{QbW%_ARmER(3+Ws0Cv9}Xh9x8`)ng;E0 zf3J}mQs5O0ZTk%Uv1SbcD~$=JX#&ip-$`q%#o%QF;gAiz2I|H{(9&^qbe!`fL3!uj zbH#!>^t3-v4V8j9Fqyk3*4=JsLO3Mtkt+?!?_i)=SdokD6ScR$Y)nDDxy5q=E;JGm zcuvx-V->>-zIv$_Tw@3^Yd_V9lpkEb`BW_K&$^T)i4u((k==X0P=k4l9*Y`Di2&3T zIe-iT3f%K{;qcI#G?+Q`#E`2ph}j5UZeiDbG? zmHHjJarQ3oo8S+WYJOC<#nmg2=7OSXaEDpQ=747u_y(tHFc_H(vnoA#Mn;$O*W^S{ zVoYA@RI5#oj)IV8d2kc35END;fn*~_jF5ii%{_xAyz93KD;BZ)kS+64N5J3;!mq`T zL!-k3)kN+>7!RQ<+=KvTbS6o)>)*f*q_kHX`n0(W?(svH6J63V#N z_&csgI9vH`_^-Ja>0rA41A|31>HQN$bmh<~`wHH`8-dt5U>V6PvCoLKUr_mR5=xLE zFpTtNR^pj*Kg?=5%ECx60$A(S!u`Q409YZS1xg8|D``mc(2Xl0f{EK7rUdUJHLpl7 z;Xo1828TaAs9F0|(XQz7n;0oEUlK|Dx<~}S(D{IuBgp9!+# z;Mt+WhCvwr!IQcQqOd8C>bCA^N6Ihh6`5E8o?chg zUUDfS$#&3ImVw99*-YU(PFfKc9BlIJpzncP46LMkDuIr0Q2qXA-p{pYxK;i9ei->` zX8zXE8CVY+)&S*nY0ZWW8(5gJGjL4+Iw5fML>K)@t)Jf<>k^((`SH^0^bZTGU2)S6 zc%=0TFoZ<7Yw3lBuWzPOR&a!WIOUG(%++2E+o{_@t0-CjKP^B9DSlu{qy-{p0WGu) z#_^O}w3(n>y@{uMo8}4^{W3R)jJYbiRnZCTXQZCvHbnY6$iR=$Y?UvIcS_RaA2AGb z!?7rwoR6wd5uf}R74eNn;@KnPlC@@BTe?sB_GCk72!32zh}sRql57fmzxDLIJvKe& z)a~mrUjHNl5m|K95Umuz{?eF(_4IGQF?aKSlkK~qd-xFQNu?ex>Lr#OhrX zBv7j{U3BdY_m*xyM!#A5n3$MKBg8wmso#3T*cw!lBCt~k-7qKj%!mCyVtXYm5aDTD z?7zdDV_6`IWWM8wdNRr`-cwBHPJHjT_0`L+Z&l}eCiOT(ZO#H(sIOGr_z6HZ>5X!H zOC#?!0tc>y)20=>9LEHA1dOqhF8X z%e5_c;Hrp~kXZ5nt9%cRNuZdue(yMlPNnp4~Y;+Ywg_ zF+GX-S$w*{*F>}IUv0DSYsGWi{LgOk8R;1K=nD{!E>eQU!f;E@Fzli!g#4X&*TI;` z*QLW5zTwP1tLT0;Th7?_&+D2tzZ}lpi2mth&SEEsv7&_%%Gqg!`VHO4sT5e%SAM=l z3#Xd?SnA{B4%8MIHYlXWoX5vERP4`|HmiDEH0d)~yp-~KLHAe9C2I(Zrj>5MkLift zqydeSbf&dBd*apCftdvK?O9_2489~v!Pa8nlH(`_eM&yheUe}) zIskgzp#cA+e`_e@g7`ydX7Q05hZy#%J!i*|!`$hgyd?2?BxzG2Imn=rt|CNC+%qnxy!JMv)4Qj|n z-2>Iq^734n{y3EKB}T0(E6z;Qchs`VfmN^jbt_7Ie$j7P(XNS`-aI5>5)J+ClHnOG z`8m9J%at#YvBk+3ZJB9$zI3F)d(Sh_Ts`N#7*h2Ce|GJ!ITIdNe>_{wrcup`oGS3Y zS@qbmM_;~vT~cSrE{LW5Sxv?==wSc}6GUFT!>I!1YT`eo7dDqKaQ1lRA?4khgY>?) z9)D-yPxF$Mc^|5uy_CtFl0jRwew_1hbavhmJ0@-u8R~6=Ty=FVvEKT;@KWig-%_U# zX0tTliL=hW|6tS;ZYiBfzjXLY<2LWzqPFbxJ3 z1S7bL(U(%E4N193#_i1P_7|4}z&Y`(!qZqbmWjTRb7AO|fIa#WO?^i?->-6;s;X}G ztoe}PNLrO&CF7uc(oH2l;W*^@Q@?-t5DNChmKq*e12&^pWyo(5TuD&r5~$`*Atvm& zWoBu`TpH$pvp@JyypQIfMxXe(3l=1hDttWi{MM3^di3u7{gqMq*pV(Sd%oNBNA~7M zswco3FlyWB`j_(=sTY^biFkU#((+#hZ<#hXVxcVZGFnHi?cKqJnxMei6U4$Z$ali_ zwk2ObMIzbUp%(S2(weLQcF}^b;-Vp|m~i*d*sgpcsvr#n^?ILtt2$@xT=;J1&*y`> zG+(^O@PCex{hNop;f6H#j0Rx(!AxQpuN{Dm(rW>wj~y?Fdf`NcPNEKQC*Kg}qgRCN#xC!3z>pldM7(0R1M zZ-RR)5l)Sief{i_eMUW>CmzL_KiLm%sP-E}BXVSkLq9C<;DK5B`~uvYh77OP{MECk zx6x*5%V5&m7H8M3p9u8~+?!@o~_x*OzPRek~p|#<}B-n4KZ|nbT&q zNFRD|hVB8q-k0NhEPOmIebt=EDcf#(^ci{Mbk5TShp&0unybHXWN_3(cRP!iFI7L> z%Dwar9=s{Kv}eQWj77KKlrE^K^1c0bSEYCIjSv<71QK~o&K@y~RHO47cqsGsZEc`q z5aDI4_oT62q~d%|esc+p$XFV(rNP!OV080COWO<#eKYRE?g?Z1Fkk|Ov0VH@Wnq|m zMx>AIK1!oF*?VE+_G1UNp1Ite9h2>GG9jTJHN8{=X@KxR9*ZhD$_zvtghXNM(nR0I zt}90W>~lVaqyBp z5qf?9f3{P*L;*q!U7-V|h|$wjAul-^Y;D)D@MFzz9z=$tt6vZZnv(12`^7J}M#Txar*uy*X zVrR!iyN?u|etxp5YmVbc*9k8R3dAT&gas(9wWj%O&*|63;NmimHusu)BQS&!&79V5 zYvt1k+aEnMTG}{sKxc!K&q9K$e3SfZmX(oMvYm=I(-+65@=vzM2p*&9B9^OE!b2EH z9@R_lUisOmM~AdNBpbY$J@(9`C&lGA^llWtLcsrmaKG{T)UiLh03IWLx8g#wQ3trW z<#(O?l6sec2lE9BdciK#Q-}-=TR>Iu%3NUpKu%-czArM; zuA=@V7=OT2*B?s;UT3&LRBfcKL#LAt%>oZgAr6C53y%?D1D0`)QbE7KX^0tD>AL&N z3#Wl+XH`CpI>avQGz6Dx($dVRWiNd)kB4KkPONh*CT8qW!>oS>W24}l+rChWU9?lL z&gVuH`Z$M$xO5%lebiu*2*Brgd8M<<>~m^^whFx(AE!N~$F6Hl>T+Z0(aIyuCvBcx zn$rp&QEaw_O+bblxFhFq;!&qRGt524lx;JqsyVNlo}Mo33DvoHVo@GORM#Gfl+d(S|?g(XO)_L>T>i%EG>fQKt9^#68yOnH< z=W1VY8SHJ?^^13TG>4V?Jb$yTN&f7ax*C?Irgi+MA2E7&=wMyJv-(?crjMQT$Bekl zwX2tumT>*l5fIeFXDBs0+a7hp0XcefIE5X7&Ou0I0^-OWMxX^>dC;c}wuAUet z4BGluq9}dLNfit2?85euz-TR;&pyBBX)=xoD8?$E_CnpDNn1s{@liy-I$wmyq``Xr|$^xmA76pG7oPAU&P?mi>aoCXlerjf>wH^}Yioc8I zhkk_kZv^XOhSEiuo*9kkq;VBve;=9}4$Vcr?VUJ>E|KsCqJwaqIYCRO+9+zY-mIwu zqJ1eQ^%!gV^6RYJ&FkuOwCvSswoFbkgEq~4c>3hYW;D|zZ2LGpPmZ}Xdx7Pn>!mKm zgD&2Z1M@DY6`ml-avSjp4p&$OPk(Ur^XO-fvQ~U5t9cXhb1(9BTUry^d`<3aS8)z2 zG^rf7$Z*ibXUzxIFVtzKHP>K*ZB5R!ZwQOV)Pz@muDV4$aS8l=L9IXZ+1KW8?Tnjp$r?#KYFSbDnuuiQCxxw;<&Z z#RKZkv#s(z$|IF$gN8+upWGd4ILf*hMbaeH#z#L-j5l+7-h(18g67y-$LH%0A1-#t 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

imU(@jjq5myRmV&cDFNAD}Cf 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 literal 0 HcmV?d00001 diff --git a/uncloud/opennebula/__init__.py b/uncloud_django_based/uncloud/opennebula/__init__.py similarity index 100% rename from uncloud/opennebula/__init__.py rename to uncloud_django_based/uncloud/opennebula/__init__.py diff --git a/uncloud/opennebula/admin.py b/uncloud_django_based/uncloud/opennebula/admin.py similarity index 100% rename from uncloud/opennebula/admin.py rename to uncloud_django_based/uncloud/opennebula/admin.py diff --git a/uncloud/opennebula/apps.py b/uncloud_django_based/uncloud/opennebula/apps.py similarity index 100% rename from uncloud/opennebula/apps.py rename to uncloud_django_based/uncloud/opennebula/apps.py diff --git a/uncloud/opennebula/management/commands/opennebula-synchosts.py b/uncloud_django_based/uncloud/opennebula/management/commands/opennebula-synchosts.py similarity index 100% rename from uncloud/opennebula/management/commands/opennebula-synchosts.py rename to uncloud_django_based/uncloud/opennebula/management/commands/opennebula-synchosts.py diff --git a/uncloud/opennebula/management/commands/opennebula-syncvms.py b/uncloud_django_based/uncloud/opennebula/management/commands/opennebula-syncvms.py similarity index 100% rename from uncloud/opennebula/management/commands/opennebula-syncvms.py rename to uncloud_django_based/uncloud/opennebula/management/commands/opennebula-syncvms.py diff --git a/uncloud/opennebula/management/commands/opennebula-to-uncloud.py b/uncloud_django_based/uncloud/opennebula/management/commands/opennebula-to-uncloud.py similarity index 100% rename from uncloud/opennebula/management/commands/opennebula-to-uncloud.py rename to uncloud_django_based/uncloud/opennebula/management/commands/opennebula-to-uncloud.py diff --git a/uncloud/opennebula/migrations/0001_initial.py b/uncloud_django_based/uncloud/opennebula/migrations/0001_initial.py similarity index 100% rename from uncloud/opennebula/migrations/0001_initial.py rename to uncloud_django_based/uncloud/opennebula/migrations/0001_initial.py diff --git a/uncloud/opennebula/migrations/0002_auto_20200225_1335.py b/uncloud_django_based/uncloud/opennebula/migrations/0002_auto_20200225_1335.py similarity index 100% rename from uncloud/opennebula/migrations/0002_auto_20200225_1335.py rename to uncloud_django_based/uncloud/opennebula/migrations/0002_auto_20200225_1335.py diff --git a/uncloud/opennebula/migrations/0003_auto_20200225_1428.py b/uncloud_django_based/uncloud/opennebula/migrations/0003_auto_20200225_1428.py similarity index 100% rename from uncloud/opennebula/migrations/0003_auto_20200225_1428.py rename to uncloud_django_based/uncloud/opennebula/migrations/0003_auto_20200225_1428.py diff --git a/uncloud/opennebula/migrations/0004_auto_20200225_1816.py b/uncloud_django_based/uncloud/opennebula/migrations/0004_auto_20200225_1816.py similarity index 100% rename from uncloud/opennebula/migrations/0004_auto_20200225_1816.py rename to uncloud_django_based/uncloud/opennebula/migrations/0004_auto_20200225_1816.py diff --git a/uncloud/opennebula/migrations/__init__.py b/uncloud_django_based/uncloud/opennebula/migrations/__init__.py similarity index 100% rename from uncloud/opennebula/migrations/__init__.py rename to uncloud_django_based/uncloud/opennebula/migrations/__init__.py diff --git a/uncloud/opennebula/models.py b/uncloud_django_based/uncloud/opennebula/models.py similarity index 100% rename from uncloud/opennebula/models.py rename to uncloud_django_based/uncloud/opennebula/models.py diff --git a/uncloud/opennebula/serializers.py b/uncloud_django_based/uncloud/opennebula/serializers.py similarity index 100% rename from uncloud/opennebula/serializers.py rename to uncloud_django_based/uncloud/opennebula/serializers.py diff --git a/uncloud/opennebula/tests.py b/uncloud_django_based/uncloud/opennebula/tests.py similarity index 100% rename from uncloud/opennebula/tests.py rename to uncloud_django_based/uncloud/opennebula/tests.py diff --git a/uncloud/opennebula/views.py b/uncloud_django_based/uncloud/opennebula/views.py similarity index 100% rename from uncloud/opennebula/views.py rename to uncloud_django_based/uncloud/opennebula/views.py diff --git a/uncloud/requirements.txt b/uncloud_django_based/uncloud/requirements.txt similarity index 100% rename from uncloud/requirements.txt rename to uncloud_django_based/uncloud/requirements.txt diff --git a/uncloud/uncloud/.gitignore b/uncloud_django_based/uncloud/uncloud/.gitignore similarity index 100% rename from uncloud/uncloud/.gitignore rename to uncloud_django_based/uncloud/uncloud/.gitignore diff --git a/uncloud/uncloud/__init__.py b/uncloud_django_based/uncloud/uncloud/__init__.py similarity index 100% rename from uncloud/uncloud/__init__.py rename to uncloud_django_based/uncloud/uncloud/__init__.py diff --git a/uncloud/uncloud/asgi.py b/uncloud_django_based/uncloud/uncloud/asgi.py similarity index 100% rename from uncloud/uncloud/asgi.py rename to uncloud_django_based/uncloud/uncloud/asgi.py diff --git a/uncloud/uncloud/management/commands/uncloud.py b/uncloud_django_based/uncloud/uncloud/management/commands/uncloud.py similarity index 100% rename from uncloud/uncloud/management/commands/uncloud.py rename to uncloud_django_based/uncloud/uncloud/management/commands/uncloud.py diff --git a/uncloud/uncloud/models.py b/uncloud_django_based/uncloud/uncloud/models.py similarity index 100% rename from uncloud/uncloud/models.py rename to uncloud_django_based/uncloud/uncloud/models.py diff --git a/uncloud/uncloud/secrets_sample.py b/uncloud_django_based/uncloud/uncloud/secrets_sample.py similarity index 100% rename from uncloud/uncloud/secrets_sample.py rename to uncloud_django_based/uncloud/uncloud/secrets_sample.py diff --git a/uncloud/uncloud/settings.py b/uncloud_django_based/uncloud/uncloud/settings.py similarity index 100% rename from uncloud/uncloud/settings.py rename to uncloud_django_based/uncloud/uncloud/settings.py diff --git a/uncloud/uncloud/urls.py b/uncloud_django_based/uncloud/uncloud/urls.py similarity index 100% rename from uncloud/uncloud/urls.py rename to uncloud_django_based/uncloud/uncloud/urls.py diff --git a/uncloud/uncloud/wsgi.py b/uncloud_django_based/uncloud/uncloud/wsgi.py similarity index 100% rename from uncloud/uncloud/wsgi.py rename to uncloud_django_based/uncloud/uncloud/wsgi.py diff --git a/uncloud/uncloud_auth/__init__.py b/uncloud_django_based/uncloud/uncloud_auth/__init__.py similarity index 100% rename from uncloud/uncloud_auth/__init__.py rename to uncloud_django_based/uncloud/uncloud_auth/__init__.py diff --git a/uncloud/uncloud_auth/admin.py b/uncloud_django_based/uncloud/uncloud_auth/admin.py similarity index 100% rename from uncloud/uncloud_auth/admin.py rename to uncloud_django_based/uncloud/uncloud_auth/admin.py diff --git a/uncloud/uncloud_auth/apps.py b/uncloud_django_based/uncloud/uncloud_auth/apps.py similarity index 100% rename from uncloud/uncloud_auth/apps.py rename to uncloud_django_based/uncloud/uncloud_auth/apps.py diff --git a/uncloud/uncloud_auth/migrations/0001_initial.py b/uncloud_django_based/uncloud/uncloud_auth/migrations/0001_initial.py similarity index 100% rename from uncloud/uncloud_auth/migrations/0001_initial.py rename to uncloud_django_based/uncloud/uncloud_auth/migrations/0001_initial.py diff --git a/uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py b/uncloud_django_based/uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py similarity index 100% rename from uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py rename to uncloud_django_based/uncloud/uncloud_auth/migrations/0002_auto_20200318_1343.py diff --git a/uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py b/uncloud_django_based/uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py similarity index 100% rename from uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py rename to uncloud_django_based/uncloud/uncloud_auth/migrations/0003_auto_20200318_1345.py diff --git a/uncloud/uncloud_auth/migrations/__init__.py b/uncloud_django_based/uncloud/uncloud_auth/migrations/__init__.py similarity index 100% rename from uncloud/uncloud_auth/migrations/__init__.py rename to uncloud_django_based/uncloud/uncloud_auth/migrations/__init__.py diff --git a/uncloud/uncloud_auth/models.py b/uncloud_django_based/uncloud/uncloud_auth/models.py similarity index 100% rename from uncloud/uncloud_auth/models.py rename to uncloud_django_based/uncloud/uncloud_auth/models.py diff --git a/uncloud/uncloud_auth/serializers.py b/uncloud_django_based/uncloud/uncloud_auth/serializers.py similarity index 100% rename from uncloud/uncloud_auth/serializers.py rename to uncloud_django_based/uncloud/uncloud_auth/serializers.py diff --git a/uncloud/uncloud_auth/views.py b/uncloud_django_based/uncloud/uncloud_auth/views.py similarity index 100% rename from uncloud/uncloud_auth/views.py rename to uncloud_django_based/uncloud/uncloud_auth/views.py diff --git a/uncloud/uncloud_net/__init__.py b/uncloud_django_based/uncloud/uncloud_net/__init__.py similarity index 100% rename from uncloud/uncloud_net/__init__.py rename to uncloud_django_based/uncloud/uncloud_net/__init__.py diff --git a/uncloud/uncloud_net/admin.py b/uncloud_django_based/uncloud/uncloud_net/admin.py similarity index 100% rename from uncloud/uncloud_net/admin.py rename to uncloud_django_based/uncloud/uncloud_net/admin.py diff --git a/uncloud/uncloud_net/apps.py b/uncloud_django_based/uncloud/uncloud_net/apps.py similarity index 100% rename from uncloud/uncloud_net/apps.py rename to uncloud_django_based/uncloud/uncloud_net/apps.py diff --git a/uncloud/uncloud_net/models.py b/uncloud_django_based/uncloud/uncloud_net/models.py similarity index 100% rename from uncloud/uncloud_net/models.py rename to uncloud_django_based/uncloud/uncloud_net/models.py diff --git a/uncloud/uncloud_net/tests.py b/uncloud_django_based/uncloud/uncloud_net/tests.py similarity index 100% rename from uncloud/uncloud_net/tests.py rename to uncloud_django_based/uncloud/uncloud_net/tests.py diff --git a/uncloud/uncloud_net/views.py b/uncloud_django_based/uncloud/uncloud_net/views.py similarity index 100% rename from uncloud/uncloud_net/views.py rename to uncloud_django_based/uncloud/uncloud_net/views.py diff --git a/uncloud/uncloud_pay/__init__.py b/uncloud_django_based/uncloud/uncloud_pay/__init__.py similarity index 100% rename from uncloud/uncloud_pay/__init__.py rename to uncloud_django_based/uncloud/uncloud_pay/__init__.py diff --git a/uncloud/uncloud_pay/admin.py b/uncloud_django_based/uncloud/uncloud_pay/admin.py similarity index 100% rename from uncloud/uncloud_pay/admin.py rename to uncloud_django_based/uncloud/uncloud_pay/admin.py diff --git a/uncloud/uncloud_pay/apps.py b/uncloud_django_based/uncloud/uncloud_pay/apps.py similarity index 100% rename from uncloud/uncloud_pay/apps.py rename to uncloud_django_based/uncloud/uncloud_pay/apps.py diff --git a/uncloud/uncloud_pay/helpers.py b/uncloud_django_based/uncloud/uncloud_pay/helpers.py similarity index 100% rename from uncloud/uncloud_pay/helpers.py rename to uncloud_django_based/uncloud/uncloud_pay/helpers.py diff --git a/uncloud/uncloud_pay/management/commands/charge-negative-balance.py b/uncloud_django_based/uncloud/uncloud_pay/management/commands/charge-negative-balance.py similarity index 100% rename from uncloud/uncloud_pay/management/commands/charge-negative-balance.py rename to uncloud_django_based/uncloud/uncloud_pay/management/commands/charge-negative-balance.py diff --git a/uncloud/uncloud_pay/management/commands/generate-bills.py b/uncloud_django_based/uncloud/uncloud_pay/management/commands/generate-bills.py similarity index 100% rename from uncloud/uncloud_pay/management/commands/generate-bills.py rename to uncloud_django_based/uncloud/uncloud_pay/management/commands/generate-bills.py diff --git a/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py b/uncloud_django_based/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py similarity index 100% rename from uncloud/uncloud_pay/management/commands/handle-overdue-bills.py rename to uncloud_django_based/uncloud/uncloud_pay/management/commands/handle-overdue-bills.py diff --git a/uncloud/uncloud_pay/migrations/0001_initial.py b/uncloud_django_based/uncloud/uncloud_pay/migrations/0001_initial.py similarity index 100% rename from uncloud/uncloud_pay/migrations/0001_initial.py rename to uncloud_django_based/uncloud/uncloud_pay/migrations/0001_initial.py diff --git a/uncloud/uncloud_pay/migrations/__init__.py b/uncloud_django_based/uncloud/uncloud_pay/migrations/__init__.py similarity index 100% rename from uncloud/uncloud_pay/migrations/__init__.py rename to uncloud_django_based/uncloud/uncloud_pay/migrations/__init__.py diff --git a/uncloud/uncloud_pay/models.py b/uncloud_django_based/uncloud/uncloud_pay/models.py similarity index 100% rename from uncloud/uncloud_pay/models.py rename to uncloud_django_based/uncloud/uncloud_pay/models.py diff --git a/uncloud/uncloud_pay/serializers.py b/uncloud_django_based/uncloud/uncloud_pay/serializers.py similarity index 100% rename from uncloud/uncloud_pay/serializers.py rename to uncloud_django_based/uncloud/uncloud_pay/serializers.py diff --git a/uncloud/uncloud_pay/stripe.py b/uncloud_django_based/uncloud/uncloud_pay/stripe.py similarity index 100% rename from uncloud/uncloud_pay/stripe.py rename to uncloud_django_based/uncloud/uncloud_pay/stripe.py diff --git a/uncloud/uncloud_pay/tests.py b/uncloud_django_based/uncloud/uncloud_pay/tests.py similarity index 100% rename from uncloud/uncloud_pay/tests.py rename to uncloud_django_based/uncloud/uncloud_pay/tests.py diff --git a/uncloud/uncloud_pay/views.py b/uncloud_django_based/uncloud/uncloud_pay/views.py similarity index 100% rename from uncloud/uncloud_pay/views.py rename to uncloud_django_based/uncloud/uncloud_pay/views.py diff --git a/uncloud/uncloud_storage/__init__.py b/uncloud_django_based/uncloud/uncloud_storage/__init__.py similarity index 100% rename from uncloud/uncloud_storage/__init__.py rename to uncloud_django_based/uncloud/uncloud_storage/__init__.py diff --git a/uncloud/uncloud_storage/admin.py b/uncloud_django_based/uncloud/uncloud_storage/admin.py similarity index 100% rename from uncloud/uncloud_storage/admin.py rename to uncloud_django_based/uncloud/uncloud_storage/admin.py diff --git a/uncloud/uncloud_storage/apps.py b/uncloud_django_based/uncloud/uncloud_storage/apps.py similarity index 100% rename from uncloud/uncloud_storage/apps.py rename to uncloud_django_based/uncloud/uncloud_storage/apps.py diff --git a/uncloud/uncloud_storage/models.py b/uncloud_django_based/uncloud/uncloud_storage/models.py similarity index 100% rename from uncloud/uncloud_storage/models.py rename to uncloud_django_based/uncloud/uncloud_storage/models.py diff --git a/uncloud/uncloud_storage/tests.py b/uncloud_django_based/uncloud/uncloud_storage/tests.py similarity index 100% rename from uncloud/uncloud_storage/tests.py rename to uncloud_django_based/uncloud/uncloud_storage/tests.py diff --git a/uncloud/uncloud_storage/views.py b/uncloud_django_based/uncloud/uncloud_storage/views.py similarity index 100% rename from uncloud/uncloud_storage/views.py rename to uncloud_django_based/uncloud/uncloud_storage/views.py diff --git a/uncloud/uncloud_vm/__init__.py b/uncloud_django_based/uncloud/uncloud_vm/__init__.py similarity index 100% rename from uncloud/uncloud_vm/__init__.py rename to uncloud_django_based/uncloud/uncloud_vm/__init__.py diff --git a/uncloud/uncloud_vm/admin.py b/uncloud_django_based/uncloud/uncloud_vm/admin.py similarity index 100% rename from uncloud/uncloud_vm/admin.py rename to uncloud_django_based/uncloud/uncloud_vm/admin.py diff --git a/uncloud/uncloud_vm/apps.py b/uncloud_django_based/uncloud/uncloud_vm/apps.py similarity index 100% rename from uncloud/uncloud_vm/apps.py rename to uncloud_django_based/uncloud/uncloud_vm/apps.py diff --git a/uncloud/uncloud_vm/management/commands/vm.py b/uncloud_django_based/uncloud/uncloud_vm/management/commands/vm.py similarity index 100% rename from uncloud/uncloud_vm/management/commands/vm.py rename to uncloud_django_based/uncloud/uncloud_vm/management/commands/vm.py diff --git a/uncloud/uncloud_vm/migrations/0001_initial.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0001_initial.py similarity index 100% rename from uncloud/uncloud_vm/migrations/0001_initial.py rename to uncloud_django_based/uncloud/uncloud_vm/migrations/0001_initial.py diff --git a/uncloud/uncloud_vm/migrations/0002_auto_20200305_1321.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0002_auto_20200305_1321.py similarity index 100% rename from uncloud/uncloud_vm/migrations/0002_auto_20200305_1321.py rename to uncloud_django_based/uncloud/uncloud_vm/migrations/0002_auto_20200305_1321.py diff --git a/uncloud/uncloud_vm/migrations/0003_remove_vmhost_vms.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0003_remove_vmhost_vms.py similarity index 100% rename from uncloud/uncloud_vm/migrations/0003_remove_vmhost_vms.py rename to uncloud_django_based/uncloud/uncloud_vm/migrations/0003_remove_vmhost_vms.py diff --git a/uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py similarity index 100% rename from uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py rename to uncloud_django_based/uncloud/uncloud_vm/migrations/0004_remove_vmproduct_vmid.py diff --git a/uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py similarity index 100% rename from uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py rename to uncloud_django_based/uncloud/uncloud_vm/migrations/0005_auto_20200321_1058.py diff --git a/uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py similarity index 100% rename from uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py rename to uncloud_django_based/uncloud/uncloud_vm/migrations/0006_auto_20200322_1758.py diff --git a/uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py similarity index 100% rename from uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py rename to uncloud_django_based/uncloud/uncloud_vm/migrations/0007_vmhost_vmcluster.py diff --git a/uncloud/uncloud_vm/migrations/__init__.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/__init__.py similarity index 100% rename from uncloud/uncloud_vm/migrations/__init__.py rename to uncloud_django_based/uncloud/uncloud_vm/migrations/__init__.py diff --git a/uncloud/uncloud_vm/models.py b/uncloud_django_based/uncloud/uncloud_vm/models.py similarity index 100% rename from uncloud/uncloud_vm/models.py rename to uncloud_django_based/uncloud/uncloud_vm/models.py diff --git a/uncloud/uncloud_vm/serializers.py b/uncloud_django_based/uncloud/uncloud_vm/serializers.py similarity index 100% rename from uncloud/uncloud_vm/serializers.py rename to uncloud_django_based/uncloud/uncloud_vm/serializers.py diff --git a/uncloud/uncloud_vm/tests.py b/uncloud_django_based/uncloud/uncloud_vm/tests.py similarity index 100% rename from uncloud/uncloud_vm/tests.py rename to uncloud_django_based/uncloud/uncloud_vm/tests.py diff --git a/uncloud/uncloud_vm/views.py b/uncloud_django_based/uncloud/uncloud_vm/views.py similarity index 100% rename from uncloud/uncloud_vm/views.py rename to uncloud_django_based/uncloud/uncloud_vm/views.py diff --git a/uncloud/ungleich_service/__init__.py b/uncloud_django_based/uncloud/ungleich_service/__init__.py similarity index 100% rename from uncloud/ungleich_service/__init__.py rename to uncloud_django_based/uncloud/ungleich_service/__init__.py diff --git a/uncloud/ungleich_service/admin.py b/uncloud_django_based/uncloud/ungleich_service/admin.py similarity index 100% rename from uncloud/ungleich_service/admin.py rename to uncloud_django_based/uncloud/ungleich_service/admin.py diff --git a/uncloud/ungleich_service/apps.py b/uncloud_django_based/uncloud/ungleich_service/apps.py similarity index 100% rename from uncloud/ungleich_service/apps.py rename to uncloud_django_based/uncloud/ungleich_service/apps.py diff --git a/uncloud/ungleich_service/migrations/0001_initial.py b/uncloud_django_based/uncloud/ungleich_service/migrations/0001_initial.py similarity index 100% rename from uncloud/ungleich_service/migrations/0001_initial.py rename to uncloud_django_based/uncloud/ungleich_service/migrations/0001_initial.py diff --git a/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py b/uncloud_django_based/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py similarity index 100% rename from uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py rename to uncloud_django_based/uncloud/ungleich_service/migrations/0002_matrixserviceproduct_extra_data.py diff --git a/uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py b/uncloud_django_based/uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py similarity index 100% rename from uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py rename to uncloud_django_based/uncloud/ungleich_service/migrations/0003_auto_20200322_1758.py diff --git a/uncloud/ungleich_service/migrations/__init__.py b/uncloud_django_based/uncloud/ungleich_service/migrations/__init__.py similarity index 100% rename from uncloud/ungleich_service/migrations/__init__.py rename to uncloud_django_based/uncloud/ungleich_service/migrations/__init__.py diff --git a/uncloud/ungleich_service/models.py b/uncloud_django_based/uncloud/ungleich_service/models.py similarity index 100% rename from uncloud/ungleich_service/models.py rename to uncloud_django_based/uncloud/ungleich_service/models.py diff --git a/uncloud/ungleich_service/serializers.py b/uncloud_django_based/uncloud/ungleich_service/serializers.py similarity index 100% rename from uncloud/ungleich_service/serializers.py rename to uncloud_django_based/uncloud/ungleich_service/serializers.py diff --git a/uncloud/ungleich_service/tests.py b/uncloud_django_based/uncloud/ungleich_service/tests.py similarity index 100% rename from uncloud/ungleich_service/tests.py rename to uncloud_django_based/uncloud/ungleich_service/tests.py diff --git a/uncloud/ungleich_service/views.py b/uncloud_django_based/uncloud/ungleich_service/views.py similarity index 100% rename from uncloud/ungleich_service/views.py rename to uncloud_django_based/uncloud/ungleich_service/views.py diff --git a/vat_rates.csv b/uncloud_django_based/vat_rates.csv similarity index 100% rename from vat_rates.csv rename to uncloud_django_based/vat_rates.csv From c44faa7a739fa8edd64887ad9aab02d9ecd8ee73 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 3 Apr 2020 18:41:17 +0200 Subject: [PATCH 265/284] Begin to include bill output --- .../uncloud_pay/css/font/Avenir-Regular.woff2 | Bin 0 -> 23476 bytes .../uncloud_pay/css/font/AvenirLTStd-Book.otf | Bin 0 -> 27444 bytes .../static/uncloud_pay/css/font/avenir-base64 | 1 + .../static/uncloud_pay/css/font/font.css | 0 .../uncloud/static/uncloud_pay/css/font/foo | Bin 0 -> 27444 bytes .../uncloud_pay/css/font/regular-base64 | 1 + .../uncloud/static/uncloud_pay/css/style.css | 115 +++ .../uncloud/static/uncloud_pay/img/call.png | Bin 0 -> 3507 bytes .../uncloud/static/uncloud_pay/img/home.png | Bin 0 -> 3643 bytes .../static/uncloud_pay/img/logo-base64 | 499 +++++++++++ .../uncloud/static/uncloud_pay/img/logo.png | Bin 0 -> 28401 bytes .../uncloud/static/uncloud_pay/img/msg.png | Bin 0 -> 4654 bytes .../static/uncloud_pay/img/twitter.png | Bin 0 -> 4821 bytes .../uncloud/uncloud/settings.py | 2 +- uncloud_django_based/uncloud/uncloud/urls.py | 9 + .../uncloud/uncloud_pay/templates/bill.html | 815 ++++++++++++++++++ .../uncloud_pay/templates/bill.html.template | 101 +++ .../uncloud/uncloud_pay/views.py | 9 + 18 files changed, 1551 insertions(+), 1 deletion(-) create mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/css/font/Avenir-Regular.woff2 create mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/css/font/AvenirLTStd-Book.otf create mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/css/font/avenir-base64 create mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/css/font/font.css create mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/css/font/foo create mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/css/font/regular-base64 create mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/css/style.css create mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/img/call.png create mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/img/home.png create mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/img/logo-base64 create mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/img/logo.png create mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/img/msg.png create mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/img/twitter.png create mode 100644 uncloud_django_based/uncloud/uncloud_pay/templates/bill.html create mode 100644 uncloud_django_based/uncloud/uncloud_pay/templates/bill.html.template diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/css/font/Avenir-Regular.woff2 b/uncloud_django_based/uncloud/static/uncloud_pay/css/font/Avenir-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..be2045c9538483daf3979cfe227e02264f0f1a8a GIT binary patch literal 23476 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 literal 0 HcmV?d00001 diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/css/font/AvenirLTStd-Book.otf b/uncloud_django_based/uncloud/static/uncloud_pay/css/font/AvenirLTStd-Book.otf new file mode 100644 index 0000000000000000000000000000000000000000..52ab53e85d7b38c94d6a76a41b43042fd9d9eea1 GIT binary patch literal 27444 zcmd43XFwFm)-c>XL-#?QB$e6|e2E?>$-qrP5&DOYlrw80;_ujqtzR&ypd1--+z$%c)vo53W}yE%GqP^V2#9ucoin)!N| zhN8@0L;CLMNbQ8XJ2x+aw8!9Fq9H;?NryoGrtsY+IzDwugL|E=Das4drC;K;QK2P;H1Qq)Y{XO6X3f9(lrzb?@BIH8hFegra9fNE*8RTzvqCMhVWH~mxPj0yXwPIN=Y5851Ua6>Pmf>p`59Q z^ zEh08vn-UQnlMtDp>F;T8f@8uUMU(s;PXF}CgqUQFZ-B->b%Jwu&49$jSVwyYC%0Z= z+xMzV_6>l4QAMa(S(I!mL#A`D(!1CnCs2E^+G8BqQ z&_qNgr)t5Ul$IQmG9e}+H6}43#lHW9j<0p>ztkWGWrrb$R~>6UYC6i5KLLffA7bRm3M5C=uTpY9PGR0Fzk2OAF8=fWjES zEfSoFho=dn@A&)=4@7*DA=h8=>2Ja;0U(I5eW?I=`vYYYz;_1zXs7`|Z6eer(h>zV z$3d;hz)O1*&)le9Q0G9%HOLea>8yh$!m5MRW(O zGE9#>!2RB*>;YOLVAd6K)zR|5FVPh^Ceoq>-`>=><016d_WZLH|5b)z+JyPS z*x0si)3#mv4jnsnw(DZw)xpuJn{#)U9MvWdbc3fx}btL!riNbT2u3axJ)0f}4dFR$`!~MJW9#lN4eE7KP+0*Kz z9oq2|Q&ZOLIk;iQ+%JoAK5hQT*9q(2fBf+J^WtS>`KoE*>wby+_0^;)nZnUy#Yfw`z^72m=oIX=@mO5W@@$!n9S+i!(nKysI4+}wq z0Zobur}}|5qoLYS?Wr*Mnn`U3-J+IuqzBOcw3ePi&!ksMbdplZ6X^lzS?L8$Q;oGo zrLont(>Q2)Y6fZ!+weBFHtlTeZ6&s>Z6jMtTZOH)t;)8ut(R@MZDiXrBB`x~nlw;D z2dF^{HSC~vLk<1uk#soJFav713N=*J*T8FJWGYM*MSYISv=rw^WHJiYMr zT-DpEr&U!HRrOQV{VGG1uIfZ%owx-fD7A|WG1DWc731pc3#2%DDEjp?SMHu(2{ zw5a9%>t7V~pY1RP%>{jKF*S)=N7Yb!KvUmAZKig^yf%Yc4}CS6nh$#5G8kpHQk$Tq zrcp9lg({z)6R&d0~PC{c!=tc|94Je_zL~xM` z3NzA+!6W6FElhUNLWmhLlswF_7Sc;=8sistO%7*F$Ged8f$9!SN83SWg z0$`N|JYPv|fEL&X9L=YX$dh7-3E9h#SBVIPSj3}iE(js za&?PLNsWmYb;~4ea%zk=ZbHn&iAl+c6Vf75Q_|ug1+*S0>Ez_-2AWt*gf=cFVPZ@| zOloFKLTY4GvNlee1o@{#PEOOtfr4f7<04a1Oo6E6NPwMO7nwLUG9fBbM8l!KL!CEJ z^qf50M6X+a(SwS*|BrV3ztWBWRyD3yQvaXSB{c!jk(voHQ8B67IM7%lLDQcAx@WxB zq^E;As-2)69~Y@fNt~FPp#>FsVq&tUu5n@#qCl~YM4AB5H79B#B2)gP@Fr?96Vo(6 za>Vy#20-l)(eb}5HSE#fuPy?j0n{HjKo}|((MSRXIa5^2|FU2~e_64Z)aw{ntkkLF ziODJdqj7_@)M?#yBt=fJ)1-mIZl^)Ol$4}6ZKn7Up9mruBZeR{A}&!>>46QfE=Wk3 z7?~_8>lrbr(TQoPCcGl2B*A(n1z=<($E2phYR4p>>DstIFa?A(Ew%i;|a;Pm*6HMA}FiDxD_1WY)=SoLPaHz_egCGmn@L ztb}dL_Gcs5BeF5FNwVv*r?L;SU(K7C`4f1McSr;SHuXSk~ZngW85n!?1?a8_sUH zzu^seBY7)%h&)!lM*gx-;d}jHH zXLxJAD?gT>%dh88@>P6wQ?6;}roEc_G#%YEzUlI&l?oe07loU`UlFT_SFBO&ROBiy zDk>DeSgEagS`D{a-^{$3U9;KEt~WPp9@%_S^99Xcws2~Z+v0kQ7cE{{ceNg3J=6M- z^+#nN+)@brH zdd)q}Q;lHL*v8t%%f`9C6qM`zGSg{ji7 zR@c}AcoKtKu<>LPQ}^*%XUFcqF3d-rV;LeXz*+cVicKRf43YSKVt>TR3MgRuZo$f9 zGjI6lg)e%`YX;n~2sbnE)A357qr!Vvz@eMgx6YN^S*|J~?-UPGE`=@fH1DvePeQPg zKRafTLc7T4P}oJk@a88+R*reSz?`4%U3621Wh-Aiz*1{$Wn0wos0weB6)m}tzHjWM zqUHsW!FhJdn_~vaI?l86Am-LY{%Q1&i7GkH&eFxDPUeiHuSyZ{$fWr;^;hOdW{{(d+y-5$;o4I?u z>cVU5&wVd-Y2SC4jZdC`eyEDibBS>GX>CpTU;AP7uJp`_L ziUIC1fpMzTZ6Q~StgjcIyjoH)(O-4K*iFIbjTkp}Twi5h&x?=l9zFTGSS`;*mv9F@ z9EaD0JVE^SxkyeP6X)NW(j|H+9#fO5up?HZh%2$TCN9OqE4C1x%}EY+qa zB!k8QASBC^kRNFcvUfV&AKQdFl7>Z5KjFqdk;X;2j<_MVIh}rqXhIozCElZ>@g*oy zD_m6gku;L#hvB8eX*f+s(pZp5qQO~oIqq-{$yXu}(N(CZCpv)yx+X|5avQnyEr#Kb zU75njW3%+adp&*JfL|J<_+ZTh1qqu-B8ek$JVqjM7!EszBe5fPoQNYynA+P6+ixJl zi5W4Q0iHdvpMi&CGi}9~co}1MRa~S@Y^6eL|S6E}zMNg#NJ)_*UslxcUw4+SRYLs!CV-(@(mgt|E^3s2*R{Z$#MLs+z_A_&Rg+@}7&8FcNioNm`H= zot%gj;a_*fjSih!wP~j+Cz~14+Kc$@%E>|&#JuXHz1rC_J<6IMdfO46w= zB(E!8DJ)S4{mD2ZIK~ek;5p10$OfL=Mw}7O>Md~qraBv58TbmkAOE1h?Xb%X+!q+- zTB>Q|I?Q&u>i!(2d|gq&4dtz(v7Q7G=csHVQM(#s{Oeuf9D<`WOMcPea^2?=`joC( zFZk)C!eRmDa8F|892jx(((5~zd%sMTeyho4uAEwtcjc*w~XcMCEjOSoPJT zdk-DFiN-JAoq1CE3TrTp`(rQC74u{$35R*C2XyyHnA3VdH?qR=axA&7_GR4z+lO{g zl77S%+u@$@h<-mPuCXDViPTLqc*dMHYc?+1x_qe|cwx<5)c90Pb|=^DupfRca6);o+7uI_QqpL|Q;BR3wTu zcq_1q#Vt2FBslb&G+0gAvVLZ`JZO&nsQ(0@qGO)^Z?-(3S6ZpRd_zedZYV&B*!B&_4uS7uhRo8 zVVXf&)4hgMl==2NM9h@E(6i6G3m>ZS&t$$g?n7qF;)4cd3{X0w=QrX)iqvv!y*(WR zbX~?RBv=`w@2m{?g3luVUTXXR*FuW zZil>AV!J|Y@m4LY^)OD6IcGT8`bHhUI#VUj-<~eexd1yIM(%Yi{>4Z$|6q*`g=V-0 z-(%XaopfD5X*>NA;d#1wuVL(oL43tJ9mBtlfVL#_bl6LWjk>Ii z3WPuD`S({zJH-&4A^x=zPt3!%pVa(gVS+czV|}t^cLN7*_E!>BdmGY{w8C^dth`yi zyDVRw(}Qg{#+6w2^EpyHLA4G)SA2NzV zH!s|x#G1Ld!Ctj+#oJgY3!5M4Gi&D3rL$BEmoWTkJhG-V1=#@HYfv4uzDnfOw}pBP z8p#i2k*E`|#Vhd)SYC8s$z&46$WNkg3-b})41QV6Dv?c$z4qNPw%~x;w zac{7litN$j9NF_DPe0UuEsZW4pI91CIrT+v!O}v43yUDafBG=I1EA4P?_Run;ck_D4swJRNkmXD2t=+nK=jTT@Hm5XFi6pA5Gf;Q z*9>s;Mjj$c+}xWq@8I{56tQjeY3B|fTCsMQYT-3zRz~u?ROR@j4Y$=WxhqAbbPeK9 z^Havb+X?Yg_QP1e_+HR)WVt~Y%_j-bd8i-DA0r)&Dsc?xD5#j;EFNi$X70gwV!il< zSGv&jDfGV$H9maEhCGBjn9^W0X)H7qN0Y|JrjQqR5Taoug3*LBHWnc@5gId|;MILm z@FfS4fF7C-t6La-I%6$%Bw83zNC;kvd*B4djU|nV2eHB)FrvWBpTNkEAGWfr^l{A`#qZN~zfG4xO|Qk8PVS;0iOrU-h><+sT$&Ui9Nd{ zG*V5Vs?(kHi8~jnvSq0Yy%RfGLsaCV(9tS?zw7Mf^9SFEh4_lw1$n1cpwwmQHt8&1 z3e)*;zFIgWm?@GnqsNU?PMo-Z%Oo`)zIXJ@9Ieu4e26w)ogcyM-E#EI8RfBK37Lo0 ze09><_1XE#^1So;2h`#MD*}dpOjV=LWQxKdtoMLrf97fwJ4+|9I=a$;RYg*qcV5wb zWAME<)^CdMJ=>@%%25<8JTZA&Y)M}8@be?~_$@WRHpW**n$76lo+zw|;$eH-Y=+9W z=$cHo^va%7=L7SaXT(mJJZ7OejPp9YPlvaFYz~96H#cB&+~~~l^!UB1 z!t+di#KQ z;vS@r0r$Z@N=T0i*~)bcb|@i@SkMRGGO*GJCa*~_ehopaC2twhI1oFqE3=rsAoWD8 z6NZ=I`8p7|jZ&eP&_h8euw67^@>g!Yr&n&dpi6_A4=U_>p0Q`Ud{OU=C(*sHY}FXZg?& zYQUonfw1TkM%A#2;tbz>CnczyZUJpP&c*M|RP(QgPY%_3Tl*f@7ALE4D=#KLec$Bq z)_r_af&v0|m!_$_W0^jODki>EzW?z&HoH9K@Tj9|{%-NUqxsjYZ;j9M->)K)OHAm_ zsGVo6cWk=(Q|aNz$?H|bjsz$!PiD@fpG_EH9T6EFJwxSv2qqcrwxK&OTj%HQIlf6H zkNu((_inDQ<@6DXN}fF7y^ahlmx1>u~r51e#yC9}YL(&KFl3s_abT|MK14ytR4klG~ zUVt8!Siw3F&MK3~%G1!g8ru>YuhHYi&;V;d{hbXam2U3Ds`Ya>ELR(c>1(#*#^1K< zW%6!#5dKQv#b2f16dfP-{_x@9!w(;NKkV4CckhlSla6Rc0sD{?hriv01`~}A3BBK@ zqV_loK1D_gy=y}1zk$)$L`MH`eGek%$iO{FE;zta5M~ohfo;a*nkM1NMY|DxX4OG_ zbwz?hR!MR?UQ~k3OYqndFr4i!!J|vE#8-j%;){}aC54{~Wr|^7pt=C7fZnE%gV4bW zVg~jjAB7uNkqw!r7tUhJvH<3Es)vE!kH_QL3et|aY#@ED2~#!n>*uO#UpA`xt!8|( z#)Nq(2ahSdiJ=v9bFf5Rz_iz&=%m|7npC6IAfY%Hqg;Ad zE^d%3#c?$%PxKR-ud~EGb)eWfSdC}-TSI%eBKeQVU1%xA<~1Rus2z+iS6TcFw{bxv zzJxviwlaTWqnI%>V{WE0DRtYv6Z;OIJ*ytWj-NDUw7+%8shGmUM>g!(sRF8VqpFZd zWH{cDB&ry7af{Ah*9y_x^3VX(^z`&GmQ01tu-5g0cCLxTq-9 z8=YCB78V3x$7qU2Gx$6iEy(rcD&B$j>&bo=%#5uRI9rf2YmIVPA}$j~r0-47&-hwY zUSxSthg<3RP<|_JDfCtp9@(^Ki+Tymhc1m`O4#hp3wQ0X#;s(BCtq$KK7Q_`Xw{*K z%+_7|*XAnoc4kae&rOq&=HZb;)%;ff^GPKtzVA<1sfeB%JSZW4-JY|r79UZ;lF?|# zx(#9m$)4P1$W+tn5vR*=93IBpmXSCz43-(D^Fp;(xP=(0+o5wPlIJ*&J~GLnSAm@|iT`IUs?V`{;_j#WqXheHmC51}wu%ifG_g7&P%$;ilr5SD9TGFW>GxZuq`=d!y{B%FilL8|pV) zd%EOe{+X+~{BZ$*!sc>>x9Rb5y|m_ZG4dJ|9T2ZRpEm4-htl0=vNlwmH-kLXUQPM(jyK;7>#b)|cIl_B>ES}q#)cc%-jb@S8lex2o0 zP_YeD4gA_!xRX$*xP9csR~5gGx_NP2FGrtAJ=K0PF{yDy5Rnt}=l)0QlorsjiY zjg;|AXU*TbEqkjH+q`>?Z9fOSA!vEZdDA$ngqihIL8S`L_CK8<~zMJenJpQ(6RDC&{a*dzyuvm)Zu%O zp)E`VPXcjE@|zC-20JIht`Y?-rWNECQQ%vQz0pC3KZ1^}CLfs-;Ng!<4*3YMM0*lG z0t(JlM05iJ@3ICfytV|ERr6OMuyvH+)#)!vaEfk0#%{o{lTO&8c4G zqz>C(*COEw$r_6)i*$Hyo%OyF|EvJBeP;Q~;Qo3H z2snUs>AL}@KXzs@dRYpiifDjAYT}F?JXiyPEg^IE!TUL2M3;4%%7*mKKRhWMk8N1<6BEvH; zh#Y^8sY+d?IHw)LRMYC~h>XJlyAi1XV=Jig9yLGKsq&x&e%9g7GC$+O`jDIq7kU=q z&?5T0_?F^NVsG^=>)zehw|n<8-)GOt%ATplSow#B`um5TzI5rd=tBKfu!N6D$8kEr z+_bEI1*>XMxxq}F!)BnML76>=tFr~J-kLBI5q3bf*fJ)vl_mRhS4yy=WEM1z`qK{G zSRJ-Cksf!9pMI<+0jNKGOr(C&u{!EAf%?P8fchCp!epR+A4ZD(?8~j=;?#V6QYBJ_ z#4~=o4}q2J*bgXn^tiM@h(DGBgHZ;~FToE$_t*qO(IfnBtjT(dk3)4OUrK1Ogbvi< zkviN4k~iR%3LPF=0S!rpGUc!w!y}pNE6*LSuzoiBcpDXQVs(Lv*X)_|^ZY#h7y7CD zv*c{*P$X{s+(6e6j33eI_*91Fb_8pr2Jg_9o1>5xslfD@s)#t+HYk4Iq`fiwTKG-e zpK&PZNNfv2`R7Jq$z_H=doA!xaB;`%?B@LAlViff2b{c>! zi5$z#<(0n(9cbKKXeY%3gdBxdi@OnL(v5V>569i$aU$JLoq~Ng@o^5%C-4n=WCqSH z!RD`ry;8Vy*vL^(FR~8NJTiDM>3=JJOt3z37OQKi$>6+o3kz zyFC;+AbhNba}oWZ8qN!apsG)nIOI0stH3<{aRgc^+|Y=r4#G5dNX-}33`@LN)HC>- zLlFPe8}UDnK>SU-RJiR4Ic|tbj%eS$4*-4~fsP74fU5HlRGs_7VK4J1l&g+OYZ)?0@xMi0-2a zkQ!5y;f^lV;aX?W4F-#D6j*fEi56Y*V@)$adJG^PJRG%0q`R=kMf8^md%VyLbovw6 zz;T<~2)C&OyRXqlh}MY7`GUe7lJ|=&C<2j#BzwUo2!UaBeHAj9R{08Fu|4tWO{g0p z3Zd3T^b>_zFLV%arJov3-bN=oB4a1vqDD+wBP0UMEOZ{XF&Sh1>nMa6v86Qir1=9` z;=bWs{XyI&Q&hY+q3*DtNpwNM=&7iY9zc8%*czvSt+B`lMGA?am==IyS`-R7u=%G7 zH19Tw2D`;9$dx(^vgOV4fnux_d&lpMeKZWdWpSKw^OpBBSs-U}_OCwO}~1`3dFgqSJ+bIxsi->G+RZK;($! zgAUkos>d}`4hqM6QA>vqDCy5lCdQQ}CA|V*ybcAG1b6sk zne=I$ejP{**dL9=OH{B;MdL(e-(;yFCae$KM*9u?oi>R7ojed6HBgicz*X*$ zE;7?JhC$IH{5A-Q=D4S#25vIR@s9_ieEh2dwD9*PE&Op98i=w*I>GjvB$5hUfekfl%h_8@tJ0*?#CS(lAAhbJ?l>4B5wKrz}cNvYxVJ>XIoc zFllE7FeHv8=G#gJzf)c~yDxXAx-f|S>rLX zWjRZhs)QAL6xoXwEL0hD*oE24bJRFQwtDI66)RQc*I(qnQ@(#T-uJqC<-DbH=UB@_ z=Ux)pq?csiy_e`yH}I`;>8F|jik%F?F$bm3{WiZU5rZFF18;?4LSn9)f#u4a}B&8`?z$I9?VA~1Y4*-O4Mp)#<%WRfmV=p({G zsZd^1sTc)Ix^I;~q|8sup3)+l{bB38{quK{W`d$QX&$msdv71^VT@-)4LblU3$Rr> zHqWQe7YdIGr8RN5Q1Nm^nRAbjp~C}Jr9mUEx+@*My&SvW^nCo}(ly-$b?-Z8wdKl( zcT3-?javz;@CiSwyLRki`Q4&n0jJer+7KTfrNH7e#nl3>N8rSfKCoAQ*HfLFd-1_7 zrJT$n&KG{`cn+qSYhz0JTli(obH$3;sjD)SeD$PBS(B61kr6X|32n{aB980HDAL2) zcKW!C$*SpDnRBNqGp4RuHb*^kG&67A-Z{IK$8t9v+OFPpWcyV(GsRbnyZnLJ(fY&c z3wsW#HgDLza+7lXrUmm?spV<7&8Z`&ionr@LabDnDR?MGB}|^IRZdLVv^842u>_?u zCwA}4EmR&kFm>v|^C*$DQH+Kh-4BlEmmaw;QM+SSICeC0p zTYZMUa#kv=ey=Ez89U+-CPyZ8z`+cBjUpM$SeY=E+4ZQ@CL|+m@rO>^;D9AX$mI-7@n9&xGGunUw1KWt^>-Hpn z-psiv3um_Yfz4hwf9srWq$57v9JJ6AtGBM$vV3EU9Cq2vRcR}ynuEa|&M|F9J{ocvOmR|n_^ddb6RJiLk3RQpn;)5Lp-mkP^~ECshi^A zPYd`#X&*QezrggffpYjc!0+%w2OHXr?n`^ngXv&86n+x2fL=lGp^r)|B?Bd)60Iao zk|vocnFq&2*GqOt4oXf+%A^gXW2CdC3#DtNd!%{N-^~V^#h6Vtn`yShY@=C`*&Va{ zX4PhI&Ayt|GUiMZ#*+zV#xb!>HnWV`&s=26n7hme2E(z!#%xoz72A&O%DS+<*?#Oq zb`iUU-N_zikFiDUMfQcvOvcG9WzA%*WUXaAWW8nmWS%l#*+^M}Y_V*aY>jNQY?th) z?6j<0_CiMBKx%t)NAsTM{mqA&k1-E7kB8%_Gt5_*?=?SUe%!pkyvY2#d5QTI^Bd+5 z%&W{_nZGmtY$3JaEm~T%v#_^twdi9p)MA{4)?$K1ip3m@6&7nOHd*Yj$hA0SaoOUg z#UqQ?7XLsJ)DSg?!>ye`k9J1=kS`jE#-RulgL%pL+mjC**uHzup7h=E@#)Ev zRb0(pu+!pL;shI7QN5rRRUfN3s2T=DnzSZ8R9tdS;<8kj;(pluLqV=Jj+9}xcX8Kl zUMXSWs*_`x+>@eF=kbY%p6V=FpjnKJ6Y}4oDX4AzoR$5p~1tLyZz764~ZeNJ) z5*29y3nX>Lt^KzzszdS_?c~4-gOv6I@4cC;&M8q;RNj5|_VO?%6{oO4+ze461{Jx& z7PMim59*Cn92mMj!qHGKIH^EdWCRA17Ta*RO7QY94wn&BNL+C=jxL1Lf{+*VWC3+V z+^k)@vvw(UJBy1~tHask`7@K_l^Ln))~2XA{1ZN@fD?G0vx%K(kGfyHr}X$u#BD@v z5jO;RBd!C2vpDCgkhofT46Bv*BM>Lro7y0bxRa)HW^_!r{HqGD^Css&R4#jAu2i3U ze!nV*%i}Vs)~{oCc9o)eNMuUMC= zo)*PSJ8)#~VP(m+J*7nx_eM;gz3_)w>g**9hkJ|XeBSBFJ3L>I>WxF-cucU~*ofs6 zUD4=2jtg;#J0>4Gv}4acZeQA-*x0nmiQFwDbwixuD%&2nVH{ZbQ$%Q>RL^p-K7%Dl zU&PfJ&_3I8by|BD#MLRIpo+p72T(81f_gc9KSY}h7qBh-xXxmc%}&{SZ1x#tsXni8 z|4qo}en*ylWZ}^x)&lKK=E?ZSL^jl?SLe7hSSGfPOfEDMG>U8S!*=>8ySapQa(XfO z&|LNP>CD-zlhejp2l}T328`dApQIW&f$=UVPrjr4@yGm6`q70x8`WN$m|+{@HWgT( zJHPEr(c$FCZ6Kf}VR^y9VdDb>3&s~07vzSl1knWRbeU= zk!x^lWza`BSp;WTIM;EWokbhx8{B#M4Hbu7@Gu4bovj2jF{BD}YMLsp`w!XCO9@6? zb_i*uChKr5mXfj7u06*M>_75IalFcBEYrWFD(;yQ4vaQ@fE8-Iiqy8l{?;!mPTbR- zNga0(P=xb7?qJb^1G#Vz!5L42^FZK>yFwtk&Sa8GXN8Br(VUl+E0ZCtK?5TZ@aj74(ie4=Ai28o422;0<8kVLIVQAPG7uOAiB^16=zzyI3jLc zO7hwirC(}r@EkCmuB$Oq;4W|ihv-0BcCT2!`+#!$&RJPIq2oQkR}^@Q&|W-;6Gfhr zDEwU9K;L3)&yXty83!0QUA^JM{*kR!WZe=p)H^C9L6yyof_@DPr}waMdOREr-huFp z65K+^eE|F3Fuh<7yZ#&)dog{pr!5D7;V2u4193=E&{>W$wwONfYAYk|wKxN3P6Bd3+k*ax|q5Cy0B(6^#PhrWF~ zcC6_8@nc2B2iOAe4jS$28+2B$zi_s!?80bo6<4;TbkCU**kF%I3r`GI5<1)K@mK4w zHy&d;TUC52F=mHauGp8dEhQx-BQbGT#;#qvw(V2NpP;!gHkRS_s}R1?6RijR!uM1~ zQYqNpryvg7wE05Xaj=$JDHV3XDcc3!f*osgGCpMB6sg|WLnb&14;fBRKQjp9Dy7e? za25-jkioDCHw({#eN05)NJSR6cpif7Bo5ZD=C}=q7tKeUt!Ga{bEad2oY58hQ5iJp zB`lH>S*&6!l3T)gBW`&j;=D)}bA#pdc!!?L)C+>16IuaR1wXu=1!djHj1d~^$T1n- zi;WC-W<&1rLhIYZPI~xjr;dtI6;5Ju_v~16T$y)pGM9urIM@w%xD(OO_0lK=qvHd# z5CQCSGHgEb2I062G_-G$Z@4;}D+}+t7d9u_!Ccv{v|?}BDRmB~5O*7BOI1T_x_j z9mi{=xCr*$$Qp0F29^yJU5zOz4&-nfMQ_vxspR5fe>OCah134XCpQ=)t&?$ulVGff zgmEEq>?Ce63})Qe!!TMz!pIR>c$kAzXJB!WaW79b_U{| ztXzl^S}Vqt4Bg|q#GG`=vC|M6Yi=KCG8~Rm9FTDU;vWVs>pkKEkvtX26&+v#S%)y} zpfj9e1AmmeoV%7Tdc3K%2*6wAG0@b7URWodmcb$*^3GjeL?QFAFoo#=~92vtgVNt>E-P(q~Zdd&2p{S8z&I1V&*0-Kj)zl8oD|2vhB&8AI=gRL zp0eyje(@eP2f9=9{=X2_5)}u>OtPW+>#&!~B}VNXtsF5rYIwZ*0LNn3D)jUW^Ct=z zT1PI%lylvQN^a+l`ZIKvoZ?yO^@lGm`8uoQijcj4#kAriMh=_uZ}Af;2lEtg*n#EP z{WzK##wL(xV01NpX4(u|$#U`Hu|i-1ej;w8+yX7A61pa*2E6Ha2JX|VPaN@5>_adR z4NF3f_kqJYoaM)IS%~J~*sMriZ_W~6#Dqmp@SnWn8bk~jR#LOQgyyiths$>h zVXWv0qf{F}xEIS=f?zrt_*>ko^0L>j%Y7X=M;~8%d!MpboZ^ge{5Y8$OgVNs4)*E4 z88~rYo#seN@7?0JA^54eZi?A z{{+A-49Yvxy{KN`21!}~d~hP}kf$R!Clj0y!1)X14P``n!s^TQfxDR|gKGgW?0|-^ zfPXLWUx2$0?i09I5I3maZw_HOg!8~xf&T)^QX(Je5rEGZxWfo|4d3qcY)TD$aDaTl z;8@^A2kA!O#|SEbj)8U;+Zp%(<-MRy8$!AErntKH>L-RxyjV_k6L}Bmc2Le1c)zC(4Z2_J499FP^k{?a$yoL0AHEWRejR?jrv6PUM|QRz!LE?nGIs zmlu;xarGG48b=Iw+^scZConV zZep@Ff`W^bqO_E=>D@aqc|rm;ASEp+1$qWFkg054dGSkXx}OWKB{0R;L1v*oE0C>r zlnd1tZb}KFLg5aF1QT8~J=%1~0Zm84{R0$WFdWjR!fgpV>tXDz_m9=57s4$JkExHe zXo!J&;hqvxU9@YxUv~o-O&>PFHKUKxdEh{fO!?t5H3&)R_4HPHeSMsHecVcV(I0W& z^Ub8wY4J8Tv99lN)9LYa(jRd&EmC3*&J6Bzkx)K>)7U>^5+G(Mz?aZYv=gKZhjQYb zD>OYATt5hlkS$Cx&Jff0PcahO)>NkDU*hEA-DIYlGwMo8=*EEATGRa(i{aJ_(Mf1I zV3he!u{7PlgqPHG-^5;kBDPlpXqVxDCES1pd^dwP{4CfMiZF%Dp!F<4Qf%Qa4JoAZ zaI1#sD@@-J_1_WjEy9q)eH(SDqA$kMaHEOHpSo1>o;0xr(HG%MsDn*9# zaJR==xG4kbu6+!(m)2gR%4%Oy*TF9brw4bd_6T(w{5#+qYK_!g$bBDRJcPJM;GRJK z>e@R1y%a)qrC&nnmr(j8lzs`NUqb1ZRAuc0DD@J`in(speul5lQ1&z6_OP}HLe~Ix zf51c{y(nS{rp5%oN)42qt^HVg0~`T&s`j8r?f>$0YTwWYOLwORjXFlu)L z_M$7TeN?-(cI*FHsehjfsE?|R`uFMoEv0rhJRj>GkSX8=Jf(kIN*w7#*O+Qv`|IE4 z_^*+*D}Zuv;vcl(3i0_xEjD|g~`?Dtt;`@H&A}+uQC7q>w8Q8Pm6u0RrDdY zlwxZiLFmzchHlFF->duE9Khdt%>Tf~B!5tEb1|mAE&jHw7%8^czfJt7gzwaSkA|A( z*KQHNzQYiqnRs1eialle{$KOeRQ8Xy0xHCNdCcK9o*tAd)e~+BqG5C~hq0|a)dlWw z>-I;AC*0H%OohP66AyFi64Q-GN8p~GTo{|KP*>q5q8Bh4SVCEI82Q@6BLN6qU<7o8 z$INtR91GC8!}!?)9t)_?6>bFT2~R`H6CN6B@P`>X7#?$|DFjBQ(eN}d)ks5~O8}Z! zS3^^64WNduruxhwrXj$R!=r$4rxBD93s)+X+-Kr3AP3eF55 zOWyNXuz7+rAQctatLQpXO=dj=k$=!X4^+P40o{mQ%uX9s#z`J z_9yZ2gIn`bKwl7VniKEK>nlDEn!my`YrOcG+oVZN2fkTy|LL8InJMYp=QLX9Fh_D0 zTw6(FpB8sqm&_T}a?iMwFvYmXyBr$+fhTPS=*-^gsCH<@N(0RhZxs}o9OEAom7obo zPD@GE42w+7NKB4(Z04X4GjP28dj>o48X$W|JBLnn39Y{;XaZv5;dawhZG4h=iIG2C zVG1{OYDOj|raC%1IMwCuJj_qy>)n5Vx371=C{2G4k3qu&1`V_mFEMiKq4^G~t_}xP zvxaUx9J)Kg<*DM|=w=NaoE;n;dN_6K?$+IH^#A(=%-;5Ap3@8^ot*=G&z8)dO&RPp zzeG=m3;JfaJkFlj#}zbb82r$`BJD+Kw@xSSezh3m{OgUU^eyo=f5CgLy-J z-hAw3dn9PXOqg{W7ul(WkH z&2GGkD9((LrPv=^(_zs+J{&x7XPh3DHMUrG<794!wU;wH&RhAowlHdd*WTrgUlwbH z&Hk@e-A-{AzHRZFcR29&wsmb8 zdfWWgjQJNdH_Dex-ur6k=)Nu1In-Xe@!O`sJ$2NpaDO*sq{<1}V{97L{h;S0Rz1;QX&)=cWq$)N*l4nr%$ z$RxB?%m2hXhi)PiwHb_G4jp#3ThONdKHrGsIQzfwIVnQh&E63nNHL9)t=gJdIW+xV zk5uwUNa`RLVKx%6H3O+-9h$Hfb?6PCX)y{qS{pG-6SGzh%H1s-nmH;Qc!+?uYhY@! zZXW#|yEt~Mt4D5TW!BX1UE#W>Wt;rhd^|pNSiri5`DQy>{*&yBy>;}Pb){2kOP3rM zj8$QeU+(VH@=UG4rtAULB%$5ysLt(@N3)mjU;1T~nd+kX(ZQPs54(MbPT061qxfE{ zk1Zc}Jb12(8KL`}(;~kTy;tsiH>uFOxrcw~TI2PY{Y*|@v{>HntS(?xKjW#`;6^*T ze#y+A)PDK-Y5(8;^ewMR^-9r~VKiuBVFKpXY-n!fsLGAWPnzt%>&Rc{hawVlkzG7g zsvKYqV`OB3TIC>e`+|YW?dk@qkQ5^a&+S+%TwwN5LJoFNt<%KF&j1wXVrpV!WH?Zj z*#3Ica^_@dZK=e24ckR!7j#ds-K{>UMa1%T>p9IyY-t`#nEiWS_%HvkKJKEv|HpnG{;Z!Gu_IacS>HEJiL(WL0f*mC+wE|BpXv*z&}-jXKe0XUo+)Ga znVJ8J&m0x4JJQnqO`kWsU+()@Wch>sYsWJVB}S|)y7I%o!~dp!ezL;N&Qnd*hrIV_ z9Bs^a{;bJijhoeipY~>FeRhX9MOCF%|7&@2+&yH?PTMyP$ECg>F>Y);sWtU?eVkSM z1d-ck^h!Uy&wggRGGU(+<0}7E(JSng4Cf1p{w!W7*VN6T{mPBWC%0cdKyRwIRs~y2 ziS5LG9d|=WLDRUypmD2#11u8*^C_^6VqgH{8!*C4hWzB>0@R`bB%)tjl2}q&46b`* zg@Ks>XZgg)NK*L(Di?r74UYjA%q`3u2HYS)eo#@ub^tkYp>?+IXaD2`9f@0CRrrH8 z^Ss`?C%yHM^4oql-t$tv`##rHDHs?@WOPs3Z8Y=4+cZ0gl^Ol%BG6eqO^a~+-W z)FJ#RtLlsO>jkq`KD9pmQu*5y%~*%f&r5^no-nhrOywvpUSf1<{hIy9S#AEizB&Bj zmdeF*$?G_m|9&L>_^%^Z)-;1AR(W7a&;cz43I$k%9|bKtzov?{{#y4hlyVDP3Jjqt z6IA^h85jY}W-tJ?L5&TJfkgzY4SE^10RnA}a0oL2i*!K)eo&re7v=~C9xd+$JmS(& z#Xt!p#3dq)a=0eA#S6ajR_3PWmB8$0#%n*`hQdBa)%ax7%*!^P z=Udge_?6q7Ua{%l9fq?;VxIF)*FIUkVDhV|ANN_0tE}E4wjqsG+V7g9&K$9S7f*lv z((wP{E$)tjbkiLLCWmF6_Aa`rCGl6|?D?}(_H%sO<0!B6YQw+%z=n!=U4(bO(S-Y= ztt+HoeOWloS3^Zx;tPZ6C$*;wJ)sQ+CSXH>Nensvq13|c4UqPNJfyv_zl$oMF&7)u0hLKM8Mn0CRR@6R<&nXnLsQZhE8w zF9pdg$uFwZcMb?P@P=z*GO(Ajg%#=Q#R@=K;3Wm1t0D9hk}82`#RAVl1s))q3Or~v zF-cD$rv!K{1gN)U3GXd|n-XY61~ag^3w0K-Xwx(R)gDkjuxOKnITNF7GiaOv@~$#V z;{=1o-Uh7AkcG)h5^3J614KbV%Te{@6lPlK^|#N^VA3U{UeV-2d2H`7v z=6iNXZH|**6|Z<5u9Nuoh$gpT6N^8vi1cOxC7OK(jYFphWD>wA&ryao7Bo%)Hj6o- zBO4lwhV0D1CI@hA#6S{0-oVLh$b%GBECw=2JZ|tn2_hmz4df9TSqz0(1fFDwEGXFX zqw4L2Q}#cy3%3HxP=sdt1PTN z7t-Cw#C>o|^y9m3pC7p|Y+~{OmRTMM&#@UcF1iOUzI=c!qlDE)p= zbT%!WRhpy9wp7U4@v3iL;u@Uzj`h-2 z22F|YHE&~NWBF=}ZwIn};rVHFH{_p^g3+%P(wnY-P2Z4b&ou49g;iq9`gcuvt|?pN z&>WqxbisN?yGN@XUhA;_{8Qi*{%fXj4Ig*FlSyjVJMYXd1#Z(~vbxi%lUw>A^wu;- zu?3eH=WVaO{&l_eED6z>nV0^miJswz`IFNrGV8}vgWYlwJEo)`3|Y6OmN~Y3dx1z< V-?s3CmBMXbZ}l&9S`M6B1pxAc>0AH+ literal 0 HcmV?d00001 diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/css/font/avenir-base64 b/uncloud_django_based/uncloud/static/uncloud_pay/css/font/avenir-base64 new file mode 100644 index 0000000..315f277 --- /dev/null +++ b/uncloud_django_based/uncloud/static/uncloud_pay/css/font/avenir-base64 @@ -0,0 +1 @@ +T1RUTwANAIAAAwBQQkFTRT9iT7oAAFZoAAAANENGRiAEWUuJAAAMrAAAPC5EU0lHmRKQTgAAVpwAABSYR1BPUwasDVsAAE/8AAAGbEdTVUJt6HATAABM0AAAAyxPUy8yeggQQwAAAUAAAABgY21hcH7oTiAAAAjoAAADpGhlYWTUopyKAAAA3AAAADZoaGVhBwADSwAAARQAAAAkaG10eAzVKxoAAEjcAAAD9G1heHAA/VAAAAABOAAAAAZuYW1l7sVYggAAAaAAAAdGcG9zdP+4ADIAAAyMAAAAIAABAAAAAQdsjRKTBF8PPPUAAwPoAAAAALklKOMAAAAAuSUo4/9Z/uAD6AOYAAAAAwACAAAAAAAAAAEAAAL0/wwAyAPo/1n/WQPoAAEAAAAAAAAAAAAAAAAAAAD9AABQAAD9AAAAAgISAV4ABQAEAooCWAAAAEsCigJYAAABXgAyARgAAAILBQICAgMCAgSAAACvQAAgSgAAAAAAAAAAQURCRQAAACD7AgL0/wwAyAOYASAAAAABAAAAAAHUAsQAIAAgAAQAAAAYASYAAQAAAAAAAACWAAAAAQAAAAAAAQANAJYAAQAAAAAAAgAHAKMAAQAAAAAAAwAbAKoAAQAAAAAABAAVAMUAAQAAAAAABQA0ANoAAQAAAAAABgAQAQ4AAQAAAAAABwCcAR4AAQAAAAAACQAPAboAAQAAAAAACwAZAckAAQAAAAAADgAkAeIAAwABBAkAAAEsAgYAAwABBAkAAQAqAzIAAwABBAkAAgAOA1wAAwABBAkAAwA2A2oAAwABBAkABAAgA6AAAwABBAkABQBoA8AAAwABBAkABgAgA6AAAwABBAkABwE4BCgAAwABBAkACQAeBWAAAwABBAkACwAyBX4AAwABBAkADgBIBbAAAwABBAkAEAAaBfgAAwABBAkAEQAOBhJDb3B5cmlnaHQgqSAxOTg5LCAxOTk1LCAyMDAyIEFkb2JlIFN5c3RlbXMgSW5jb3Jwb3JhdGVkLiAgQWxsIFJpZ2h0cyBSZXNlcnZlZC4gqSAxOTgxLCAxOTk1LCAyMDAyIEhlaWRlbGJlcmdlciBEcnVja21hc2NoaW5lbiBBRy4gQWxsIHJpZ2h0cyByZXNlcnZlZC5BdmVuaXIgTFQgU3RkNDUgQm9vazEuMDI5O0FEQkU7QXZlbmlyTFRTdGQtQm9va0F2ZW5pciBMVCBTdGQgNDUgQm9va09URiAxLjAyOTtQUyAwMDEuMDAxO0NvcmUgMS4wLjMzO21ha2VvdGYubGliMS40LjE1ODVBdmVuaXJMVFN0ZC1Cb29rQXZlbmlyIGlzIGEgdHJhZGVtYXJrIG9mIEhlaWRlbGJlcmdlciBEcnVja21hc2NoaW5lbiBBRywgZXhjbHVzaXZlbHkgbGljZW5zZWQgdGhyb3VnaCBMaW5vdHlwZSBMaWJyYXJ5IEdtYkgsIGFuZCBtYXkgYmUgcmVnaXN0ZXJlZCBpbiBjZXJ0YWluIGp1cmlzZGljdGlvbnMuQWRyaWFuIEZydXRpZ2VyaHR0cDovL3d3dy5hZG9iZS5jb20vdHlwZWh0dHA6Ly93d3cuYWRvYmUuY29tL3R5cGUvbGVnYWwuaHRtbABDAG8AcAB5AHIAaQBnAGgAdAAgAKkAIAAxADkAOAA5ACwAIAAxADkAOQA1ACwAIAAyADAAMAAyACAAQQBkAG8AYgBlACAAUwB5AHMAdABlAG0AcwAgAEkAbgBjAG8AcgBwAG8AcgBhAHQAZQBkAC4AIAAgAEEAbABsACAAUgBpAGcAaAB0AHMAIABSAGUAcwBlAHIAdgBlAGQALgAgAKkAIAAxADkAOAAxACwAIAAxADkAOQA1ACwAIAAyADAAMAAyACAASABlAGkAZABlAGwAYgBlAHIAZwBlAHIAIABEAHIAdQBjAGsAbQBhAHMAYwBoAGkAbgBlAG4AIABBAEcALgAgAEEAbABsACAAcgBpAGcAaAB0AHMAIAByAGUAcwBlAHIAdgBlAGQALgBBAHYAZQBuAGkAcgAgAEwAVAAgAFMAdABkACAANAA1ACAAQgBvAG8AawBSAGUAZwB1AGwAYQByADEALgAwADIAOQA7AEEARABCAEUAOwBBAHYAZQBuAGkAcgBMAFQAUwB0AGQALQBCAG8AbwBrAEEAdgBlAG4AaQByAEwAVABTAHQAZAAtAEIAbwBvAGsATwBUAEYAIAAxAC4AMAAyADkAOwBQAFMAIAAwADAAMQAuADAAMAAxADsAQwBvAHIAZQAgADEALgAwAC4AMwAzADsAbQBhAGsAZQBvAHQAZgAuAGwAaQBiADEALgA0AC4AMQA1ADgANQBBAHYAZQBuAGkAcgAgAGkAcwAgAGEAIAB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jb3Jwb3JhdGVkLiBBbGwgUmlnaHRzIFJlc2VydmVkLiBBdmVuaXIgaXMgYSB0cmFkZW1hcmsgb2YgSGVpZGVsYmVyZ2VyIERydWNrbWFzY2hpbmVuIEFHLCBleGNsdXNpdmVseSBsaWNlbnNlZCB0aHJvdWdoIExpbm90eXBlIExpYnJhcnkgR21iSCwgYW5kIG1heSBiZSByZWdpc3RlcmVkIGluIGNlcnRhaW4ganVyaXNkaWN0aW9ucy4gVGhlIGRpZ2l0YWxseSBlbmNvZGVkIG1hY2hpbmUgcmVhZGFibGUgc29mdHdhcmUgZm9yIHByb2R1Y2luZyB0aGUKIFR5cGVmYWNlcyBsaWNlbnNlZCB0byB5b3UgaXMgY29weXJpZ2h0ZWQgKGMpIDE5ODksIDE5OTUsIDIwMDIgQWRvYmUgU3lzdGVtcy4KIEFsbCBSaWdodHMgUmVzZXJ2ZWQuIFRoaXMgc29mdHdhcmUgaXMgdGhlIHByb3BlcnR5IG9mIEFkb2JlIFN5c3RlbXMKIEluY29ycG9yYXRlZCBhbmQgaXRzIGxpY2Vuc29ycywgYW5kIG1heSBub3QgYmUgcmVwcm9kdWNlZCwgdXNlZCwgCiBkaXNwbGF5ZWQsIG1vZGlmaWVkLCBkaXNjbG9zZWQgb3IgdHJhbnNmZXJyZWQgd2l0aG91dCB0aGUgZXhwcmVzcyAKIHdyaXR0ZW4gYXBwcm92YWwgb2YgQWRvYmUuCiAKIFRoZSBkaWdpdGFsbHkgZW5jb2RlZCBtYWNoaW5lIHJlYWRhYmxlIG91dGxpbmUgZGF0YSBmb3IgcHJvZHVjaW5nIHRoZSBUeXBlZmFjZXMgCiBwcm92aWRlZCBhcyBwYXJ0IG9mIHlvdXIgbGFzZXIgcHJpbnRlciBpcyBjb3B5cmlnaHRlZCAoYykgMTk4MSwgMTk5NSwgMjAwMiBIZWlkZWxiZXJnZXIgRHJ1Y2ttYXNjaGluZW4gQUcuIAogQWxsIHJpZ2h0cyByZXNlcnZlZC4gVGhpcyBkYXRhIGlzIHRoZSBwcm9wZXJ0eSBvZiBIZWlkZWxiZXJnZXIgRHJ1Y2ttYXNjaGluZW4gQUcsIAogYW5kIG1heSBub3QgYmUgcmVwcm9kdWNlZCwgdXNlZCwgZGlzcGxheWVkLCBtb2RpZmllZCwgZGl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eACYANgA+AEYAVgBeAGYAbgABAAAAAQFaAAMAAAABAWgABgAAAAUAUABmAHoAjgCiAAQAAAABAKYABAAAAAEAsAAGAAAABQC6AMoA3ADuAQIAAQAAAAEBBgAEAAAAAQE4AAQAAAABAZYAAQAAAAEBrAADAAAABAGuAbQBrgGuAAAAAQAAAAcAAwAAAAMBmAGeAZgAAAABAAAACAADAAAAAwGSAYoBmAAAAAEAAAAHAAMAAAADAX4BdgGKAAAAAQAAAAcAAwAAAAMBfAFiAXAAAAABAAAABwABAW4AAQAIAAEABABtAAIASgABAVwAAQAIAAEABABuAAIATQADAAIBMgEyAAEBUAAAAAAAAwABAUYAAQFAAAAAAQAAAAkAAwABATQAAQE+AAAAAQAAAAkAAwACASIBMgABARwAAAABAAAACQADAAIBDgEeAAEBGAAAAAEAAAAJAAIBEAAHAJYApACpAIsAjwCLAI8AAgEOAAgAeQCWAKQAqQCLAI8AiwCPAAEBDAABAAgAAwBvAIkApgABAQIAAwAMACYAUAACAAYAEAB6AAQAEAARABEAegAEAGMAEQARAAQACgASABoAIgCbAAMAEAATAJ4AAwAQABUAmwADAGMAEwCeAAMAYwAVAAIABgAOAKMAAwAQABUAowADAGMAFQABACgAAQAIAAIABgAOAAYAAwAQABEABgADAGMAEQACAIgAAgCLAI8AAQABABEAAQACABAAYwABAAEAEgABAAEAFQABAAEAEwABAAEAFAABAAEARwABAAEAQgACAAEAEQAaAAAAAQABAFAAAQABAA8AAQAHABIAEwAUACIAMABCAFAAAQAIAA8AEgATABQAIgAwAEIAUAABAAEADgABAAMAEQASABQAAQACAEIAUAABAAAACgAeACwAAWxhdG4ACAAEAAAAAP//AAEAAAABa2VybgAIAAAAAQAAAAEABAACAAAAAgAKALAAAQRYAAQAAAAGABYAHABaAIQAlgCgAAEACP/cAA8Ayf/IAMr/5ADL/8gAzP/IAM3/yADQ/60A0f/IANL/rQDT//wA1AABANYABADa/8gA2//IAN3/5ADg/9IACgDJ/+QAyv/kAMv/5ADM/+QAzf/kAND/5ADR/+QA0v/kANn/1wDa/+QABADK/+QA0P/yANr/8gDg//IAAgDa/7sA4P/IAAEAQf/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 diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/css/font/font.css b/uncloud_django_based/uncloud/static/uncloud_pay/css/font/font.css new file mode 100644 index 0000000..e69de29 diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/css/font/foo b/uncloud_django_based/uncloud/static/uncloud_pay/css/font/foo new file mode 100644 index 0000000000000000000000000000000000000000..52ab53e85d7b38c94d6a76a41b43042fd9d9eea1 GIT binary patch literal 27444 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 V-?s3CmBMXbZ}l&9S`M6B1pxAc>0AH+ literal 0 HcmV?d00001 diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/css/font/regular-base64 b/uncloud_django_based/uncloud/static/uncloud_pay/css/font/regular-base64 new file mode 100644 index 0000000..1e98cef --- /dev/null +++ b/uncloud_django_based/uncloud/static/uncloud_pay/css/font/regular-base64 @@ -0,0 +1 @@ +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 diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/css/style.css b/uncloud_django_based/uncloud/static/uncloud_pay/css/style.css new file mode 100644 index 0000000..78852fb --- /dev/null +++ b/uncloud_django_based/uncloud/static/uncloud_pay/css/style.css @@ -0,0 +1,115 @@ +body { + font-family: Avenir; + background: white; + padding: 20px; + font-weight: 500; + line-height: 1.1; + font-size: 14px; + width: 600px; + margin: auto; + padding-top: 40px; + padding-bottom: 15px; + +} +p { + display: block; + -webkit-margin-before: 14px; + -webkit-margin-after: 14px; + -webkit-margin-start: 0px; + -webkit-margin-end: 0px; +} +.bold { + font-weight: bold; +} +.logo { + width: 220px; + height: 120px; +} +.d1 { + width: 60%; + float: left; + +} +.d2 { + padding-top: 15px; + width: 40%; + float: left; +} +.d4 { + width: 40%; + float: left; +} +.b1 { + width: 50%; + float: left; +} +.b2 { + width: 50%; + float: left; + text-align: right; + left: 0; +} +.d5 { + margin-top: 50px; + width: 100%; +} +.d6 { + width: 60%; + float: left; + font-size: 13px; +} +.d7 { + width: 40%; + float: left; +} +.wf { + width: 100%; +} +hr { + border: 0; + clear:both; + display: inline-block; + width: 100%; + background-color:gray; + height: 1px; + } + .tl { + text-align: left; + } + + .tr { + text-align: right; + float: right; + } + .pc p { + display: block; + -webkit-margin-before: 3px; + -webkit-margin-after: 5px; + -webkit-margin-start: 0px; + -webkit-margin-end: 0px; +} + .th { + border-top: 1px solid gray; + border-bottom: 1px solid gray; + } + .ts { + font-size: 14px; + } + .icon { + width: 16px; + height: 14px; + vertical-align: middle; + margin-right: 2px; + } + .footer { + margin-top: 70px; + font-size: 14px; + } + + .footer p { + display: block; + -webkit-margin-before: 5px; + -webkit-margin-after: 5px; + -webkit-margin-start: 0px; + -webkit-margin-end: 0px; +} \ No newline at end of file diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/img/call.png b/uncloud_django_based/uncloud/static/uncloud_pay/img/call.png new file mode 100644 index 0000000000000000000000000000000000000000..e774362528ae31636b9136ba2ff6441b2b5e7b6d GIT binary patch literal 3507 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 literal 0 HcmV?d00001 diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/img/home.png b/uncloud_django_based/uncloud/static/uncloud_pay/img/home.png new file mode 100644 index 0000000000000000000000000000000000000000..24428e7695bac0907de67679cc392972ba7353b3 GIT binary patch literal 3643 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 N002ovPDHLkV1m+}xikO( literal 0 HcmV?d00001 diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/img/logo-base64 b/uncloud_django_based/uncloud/static/uncloud_pay/img/logo-base64 new file mode 100644 index 0000000..d2d520b --- /dev/null +++ b/uncloud_django_based/uncloud/static/uncloud_pay/img/logo-base64 @@ -0,0 +1,499 @@ +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 +Rw8f1hPPPKtBpw0Jufa8fcsWffzBe1q5bKnKSksNcY/aw+2sYtpA7XnCp59q+uRJSj92tN7ve3UN +PA7s26cjh97UimVLdPPtd+rSK65slqsRbtqwXhO/nKA1K1cqJ/t4s20X5WVlWrt6lWZOn6ad27Yq +Lze3zkPAa8vr8fzYQ2z3zp2aNeNbDR0+Qjf86hadPmJESLYHm81a66GLSxYu0Kcfvq8d27b97NDO ++vC43co+nqXs41nauG6dFi+Yr3seeEhdunXjTQ4AADQ6gq4mVFVZqc2bNmrW9OnatGG9so9nBWW4 +238+mLPqYuCqq6uVn5f3yw9EXq++nTJZ7/zjTR07eqTRh4j6/X5t2bRRzzzxuJ7/6ysh0+vA6XRq +0lcT9MHbbysn+7ihFkTgXgpyqOD1at2a1frXG69r2+ZNctehV0mwud1ubdu8WWkHDmjLxg26/3cP +q2Wr1qFflx6PNm/coMnffK1Vy5epID+/3r2dmvIaLF4wX9988bl2bt+u0tKSJnkf8Xq9ysvN1ZyZ +M7R6+XKNPvts3XXf/erVp29IvRdERkYpMjLyF7c5cviQPnznbc2eOUPlZWUNfkylpSWaMXWKdm7f +pt/94VGdd9HFzTI0BgAAzRdBVyOHE1WVlTqelaXVK5dr+uTJ2pe6R263mxUSQ+yhseQXhosWFRbq +rddf0+Svv6zVnF8Nac+unfrzE4/p1X/8Uz179W7SY8nNydHbb76ub76c0GDDN2EMJSXF+mbCF3rv +rX+GZI+/stJSff3F5zqwf78e/9Mz6jdgQEgGnVWVldq+das+/+QjrVy+TM6qqmb7WfJDWPfev97S +2lUrQyL4/M/2OmvGt1q5fJluvv0OXf+rm0Nmrj6//Ce95k6nU8sWL9I/X/ubDu7f36jhp8/n08H9 ++/XkH36vgvx8jb/hRtntdt78AABAoyDoaugvoX6/KisqlJOTrd07d2r5ksVatXxZjT2G0LTKy8vl +9/t/8nDr9/u1N3WP/v7SX7Ri6ZKQeaDcs2un/vG3V/XcX19Ry1atmuQY0o8e1csvPKf5c2bTePCL +MtLT9ebfXtG3UyaH9HH6fD5tWLtGf3jwfj357HMaffbYkBm6WlJSrG2bN2vyN19r2aKFQe0J3BSf +kUcOH9KXn32qSV9/pcqKipA9zuKiIr31+mtau2qV7n3otxox6kyFhYU16XHZ7eEKs//vMRzPytIX +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 +Ul1d3WCBwd7UPfrrs89ozaqVtQrhktu0Ua8+fZSRnq683FyVlpTI6/U2m3ts/uxZuuKqq9W3/4BG +fd2wsDCNv+4G9ezVWx+9+44WzJ3TKGG23W5Xl27dNWr0GJ151tnq1qNHg75efEKCLrj4Eg0dNlw3 +3nKrZkybqlXLlykvN7fRr3V0TIwGDBqsy64cp0FDhqh9SoeQXPjDSOITEnT9r27W8JEjNW/2LE2b +ODFoiyAEg8Ph0Jix5+j6X92sAYMGKzomhosGAAAQwuoXdJ0Kqxw2k3OMjIrSxZdfoUFDhmr+nNla +smC+tm7ZrKrKynrv22QyKSo6WkNOH6aLL7tcw0eOUnKbNgE/sPfp11+5OdmKiPjlnl0ul0s9+/SR +LYjDg1q1aq1+AwepvKxMDoejxnDG5/PK7faoc9duQT2OOj14R8dowKBBapGYKLvdXqtgqbq6Wr37 +9JXDEfweSNs2b9afHn9Uqbt31Wr70WeP1QMPP6JOnbvI6XQqKzNDRw8fVn5+nooLi+T1eeXzelVU +VCSXyyVzHec9MpvNqq6uVklJsYoKC1VUWKjy8nJVO531DtWyMjO1fetW9enXv0nmZerbf4Cee/kV +XX7VeE2b9I02rl+nosLCoAWZYWFhsoeHq1efvurTr59GjDxTffr1VWxcvMIbcbGPhBYtNGrMWRow +aLAyMtK1bvVqbVy3Vtu3blVpSbGcTmfQezfa7XY5HA71GzhIQ4cN1+kjRqhT5y5qkZjYrD6qPJ7A +27jP7wup3qIdO3XWr+++V+dfdLEWzpurOTNn6tDBA6qqqmqS42nZqrVGn3W2Lr3ySvUdMLDWP+zU +lddXh2vo84VED8i6tCO/3y+fzysAAIBgq98TfCOvRtYkQnTOmJNJbtNGN992u64cf412bd+mNStX +auvmTcpIP6aqqio5q6pUXV190i+lJpNJ4Q7Hjz2wOnTqpGFnnKHhZ4xSpy6dFRUVLbM58MU6I6Oi +dNd99+mWO+6Q2Wyp8Yt7uCM84KGOv2TYGSPVq09f+f1+WSyWWn0p9/m8Cnc4gnocddG5Sxe9+Orf +5Xa7Azh2n2w2m2JiY4N6LNs2b9aTf3xE+/furXFbR0SEbr3zLt1656+V1LLlT9rooNOGyOf1yuv7 +/wdtn9db74duv98vj8ej8vJyVVZUKCf7uPbv3atNG9Zrx7Ztys/LrXPotXPbNpWNGxdQz8Rgio2N +09jzztPpw4cr/dhRrV21SqtXrtChtIMqKy1VdXW1nFVVv1iHFqtV4eHhcnx/j7dqnaxeffuqX/8B +6tK1m1I6dlREZGSjhls/JzomRr1691H37j10zfU3KCc7W4fSDmrb5s3auX2bMjMyVFlZIafTqWqn +s8ZebiaTSTabTWHfh1p2u11JLVupd79+GjT4NHXt3l3tO3RQRERks+29FWYPk8lkkt1ur9X21dXV +slptITehvtVmU+cuXXXHb+7RlVeP15aNG7Vk0cIfw92K8vIGe22z2ay4+Hi1T0nReRdepLPOPU8d +OnRstCHL1u9/VLHb7bW6Lh6vVzarNSSGUJq/P4bavnf8EM7RWxIAADQEk78eT5Zlr70qz6E0mUxm +w1WM3++TPF7ZzzlXEVeNl5rp5MMet1tuj0cFeXnas3uXsjIzlZudrcqqSrldLlWUl8vn9ysyMlL2 +8HBFOCLULiVF7dq3V8/efRQTG3viizSTL5/yDuzbp98/eL/27NpZ47bxCQl69Kk/adz4a2Rt4gcZ +r8cjj9erjPRj+vyTjzVr+nSVlBQHvJ8Ro0bp1TffCrg3Y4Odl9crt9ut4qJC7UtNVUZ6uo5nZqqi +okIVFeWqqqqSyWSS1WJRRGSk7PZwxcXHq227dmrdpo26dO2qmNg4Wb9/UK5LgN2478n+H69lZUWF +jhw+pPRjx5SXk6O8vNwTQ3W93hPDYn0+2axWRcfEyGQ2Kzw8XElJLdUiMVFt2rZVx86dFR0T22zO +vSaVFRVavHCBsrOyZLXVLryqrnaqdetknXfhRU0e5td03d1utwry87Vm5Qpt37pFhw+lKTMjQ2Wl +paqqrJTb7Q44wP4hFIyMilJ8QoLap3RQz969NXzkKA0afJrsdnujfu55PR6tW7tGe3bulNVqlamG +NmkySW7XiR8/Ro05q8GHF9fk4P79WjB3jiKjImvVEd7v88nr9arfgIEadsYZfMACAICgqlfQJb/f ++MMXTaYTfwziPy/3f1/6/3w4MhnonFF/TqdTT/3x95oxdUqN20ZGRekPTzypX912R8i1I5/Pp8lf +f6XXXn5JhQUFAZXt3rOn3nr/I3Xp1q3Z3dv/eU8b7d6u7Xua0d/X6vpR3tzqxOfzqaK8XAf279Px +rCzlHD+u48ezdDwzU2WlpfL6vCovK5fb7ZJJJ87N5/fJYrYoMipKYWFhioqOVqvWyWrXvr3atmun +Dp06qXPXbgoLC2vy+qjLdQyVa9icjx0AABhL/X6uNFgIdCogzEJdHiwnfTVBC+bMrnFbs9msK666 +WuOvvzEk25fZbNb462/Q7p079M2ELwKa28bpdMrldnFvc94hXw9GZjabFR0To8FDhv7k791ut5xV +VfJ6vSosLFB1dfWPdeL3+WQ2WxQXHydHRITs9vBaD/HkOtIGAQBA88N4NAC/6PChNE2dOLFWE0Kn +dOiocddcK4fDEbLnY7FYdP5FF2vR/PnKzcmufTmzRRazhQYBhCCbzfbjfE9x8fFUCAAAwCnMTBUA +OBmfz6flixdrb+qemt9MzGaNGDVKvfr0Dfnz6ty1mxISEgKuC5/fR6MAAAAAgBBG0AXgpAry87Vx +/Tp5PZ4at42IjNTAwac1+Yp9tREbG6uo6OiAylitVnp0AQAAAECII+gCcFJZmZlKO3CgVts6HA7F +J7RoFudVXV0tl6s6oDIJiYkhvTodAAAAAICgC8AvyM3JVmFh7VYntFissofbm8V5pR87pqKiooDK +tE9JUVR0FI0CAAAAAEIYQReAk6qsqJDLVbuVBt1ul5y1mLA+FKxfu1q52dkBlek/YKCioqJpFAAA +AAAQwgi6AJyU3++X3++v1baVFRU6dvRoyJ/TkcOHNG/Wd6qurv3QxZatWqlv/wGyWJijCwAAAABC +GUEXgJOyWK21DnecTqeWL16k/Ly8kD2fqqoqffD2v7R7166Ayo05+xz17NOHBgEAAAAAIY6gC8BJ +tWrVStG1XJ3Q7/dr88YNmjltaq17gTWmyooKvfm3V/TtlMm1WkXyB4lJSbrkiiuaxWqSAAAAAHCq +I+gCcFJx8QlKTGpZ6+2rqqr0+acfa8nCBSF1HsezsvT800/p848/qvWcYz+4cvw1Gn7GSBoDAAAA +ADQDBF0ATioxKUmdOncOqEzGsWN6/uknNWPa1IBDpWCrrKjQkoUL9cBdd2j6lElyu90BlR86bLhu +vPlWWW02GgMAAAAANANWqgDAySS0aKHBQ4Zq/pzZAU3enpmRoacf/YN2btuqq6+/Qd26dW+0sMjn +86mosFB7U/do1vTp+nbq5IADLklKbtNG9zz4kFI6dqQhAAAAAEAzYfKH4mQ6AELG4UNp+uNvH9S2 +zZvrVL5T5y66+rrrdPqIM9S9R085IiKCvnqh1+uVs6pKx44e0d49e7R08SKtWbFcxcXFddpfTEys +Hn7sMd18+500AAAAAABoRgi6ANTos48+1F+ffzagSdz/W6vWyRozdqx69u6tHj17q3WbZEVHx8jh +cMhms8lsschkMslkMp10Mnufzyefzye3y6WqqiqVlBQrKzNTh9MOav/evdqwdq3SDh6o17nGxMTq +noce0m/ue4ALDwAAAADNDEEXgBrl5uToz48/qkXz5wVlf23btVPb9imKj49Xq9bJPwm9rDbbzwZd +fr9fZaWlKist0fHjx5WXk6OC/HylHzum3JzsoBxXq9bJ+s399+vWO+/iogMAAABAM0TQBaBW9uza +qT/+9kHtS01tkP1bLBbZw8NlsVhOGnRVVVbK5/M1yOv36tNXv/3DH3XuBRdysQEAAACgmSLoAlBr +q5Yv01+ff7bBwq6mEBkVpXMvuFB3/OZu9enXn4sMAAAAAM0YQReAgGxcv05vvfZ3rVm1stmfS68+ +fXXtjTfq0ivGKT4hgYsLAAAAAM0cQReAgGUcO6YvPvtE0yZNVFFhYbM7/i5du+mc8y/QZePGqVef +vlxQAAAAADAIgi4AdeJ0OrVs8SLN/W6mli5epMqKipA/5v4DB2rk6DE694KL1KtPH4WFhXEhAQAA +AMBACLoA1EtRYaHWr1mt5UuWaPHC+SosKAip42vVOlkjR4/WwMGnadiIM5TSsaNsNhsXDgAAAAAM +iKALQFCUlBTr8ME07di+VSuWLtW2LZtVXFTU6MdhsVjUqnVr9R84SKPPHquevfsopWNHxcbGymQy +caEAAAAAwMAIugAEldfjUUFBgfJyc7QvNVV7U/focFqajh09ouNZWUEf4hgXH6/2KR3UvkMHderc +WQMGDVZKh46Ki49Xi8REwi0AAAAAOIUQdAFoMF6vVy6XS263S8WFRcrLy1VBfr4K8vOVlZmhgvx8 +VZSXKzc3V1WVlbLarD+7H7/Pp9i4eCUmJckREaHkNm3UunWy4hMSFJ/QQkktWyoqKkphdrvsdjsV +DwAAAACnKIIuAAAAAAAAGIKZKgAAAAAAAIAREHQBAAAAAADAEAi6AAAAAAAAYAgEXQAAAAAAADAE +gi4AAAAAAAAYAkEXAAAAAAAADIGgCwAAAAAAAIZA0AUAAAAAAABDIOgCAAAAAACAIRB0AQAAAAAA +wBAIugAAAAAAAGAIBF0AAAAAAAAwBIIuAAAAAAAAGAJBFwAAAAAAAAyBoAsAAAAAAACGQNAFAAAA +AAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCARdAAAAAAAAMASCLgAAAAAAABgCQRcA +AAAAAAAMgaALAAAAAAAAhkDQBQAAAAAAAEMg6AIAAAAAAIAhEHQBAAAAAADAEAi6AAAAAAAAYAgE +XQAAAAAAADAEgi4AAAAAAAAYAkEXAAAAAAAADIGgCwAAAAAAAIZA0AUAAAAAAABDIOgCAAAAAACA +IRB0AQAAAAAAwBAIugAAAAAAAGAIBF0AAAAAAAAwBIIuAAAAAAAAGAJBFwAAAAAAAAyBoAsAAAAA +AACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCARdAAAAAAAAMASCLgAA +AAAAABgCQRcAAAAAAAAMgaALAAAAAAAAhkDQBQAAAAAAAEMg6AIAAAAAAIAhEHQBAAAAAADAEAi6 +AAAAAAAAYAgEXQAAAAAAADAEgi4AAAAAAAAYAkEXAAAAAAAADIGgCwAAAAAAAIZA0AUAAAAAAABD +IOgCAAAAAACAIRB0AQAAAAAAwBAIugAAAAAAAGAIBF0AAAAAAAAwBIIuAAAAAAAAGAJBFwAAAAAA +AAyBoAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCARdAAAA +AAAAMASCLgAAAAAAABgCQRcAAAAAAAAMgaALAAAAAAAAhkDQBQAAAAAAAEMg6AIAAAAAAIAhEHQB +AAAAAADAEAi6AAAAAAAAYAgEXQAAAAAAADAEgi4AAAAAAAAYAkEXAAAAAAAADIGgCwAAAAAAAIZA +0AUAAAAAAABDIOgCAAAAAACAIRB0AQAAAAAAwBAIugAAAAAAAGAIBF0AAAAAAAAwBIIuAAAAAAAA +GAJBFwAAAAAAAAyBoAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAA +AABgCARdAAAAAAAAMASCLgAAAAAAABgCQRcAAAAAAAAMgaALAAAAAAAAhkDQBQAAAAAAAEMg6AIA +AAAAAIAhEHQBAAAAAADAEAi6AAAAAAAAYAgEXQAAAAAAADAEgi4AAAAAAAAYAkEXAAAAAAAADIGg +CwAAAAAAAIZA0AUAAAAAAABDIOgCAAAAAACAIRB0AQAAAAAAwBAIugAAAAAAAGAIBF0AAAAAAAAw +BIIuAAAAAAAAGAJBFwAAAAAAAAyBoAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAA +AMAQCLoAAAAAAABgCARdAAAAAAAAMASCLgAAAAAAABgCQRcAAAAAAAAMgaALAAAAAAAAhkDQBQAA +AAAAAEMg6AIAAAAAAIAhEHQBAAAAAADAEAi6AAAAAAAAYAgEXQAAAAAAADAEgi4AAAAAAAAYAkEX +AAAAAAAADIGgCwAAAAAAAIZA0AUAAAAAAABDIOgCAAAAAACAIRB0AQAAAAAAwBAIugAAAAAAAGAI +BF0AAAAAAAAwBIIuAAAAAAAAGAJBFwAAAAAAAAyBoAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAA +gCEQdAEAAAAAAMAQCLoAAAAAAABgCARdAAAAAAAAMASCLgAAAAAAABgCQRcAAAAAAAAMgaALAAAA +AAAAhkDQBQAAAAAAAEMg6AIAAAAAAIAhEHQBAAAAAADAEAi6AAAAAAAAYAgEXQAAAAAAADAEgi4A +AAAAAAAYAkEXAAAAAAAADIGgCwAAAAAAAIZA0AUAAAAAAABDIOgCAAAAAACAIRB0AQAAAAAAwBAI +ugAAAAAAAGAIBF0AAAAAAAAwBIIuAAAAAAAAGAJBFwAAAAAAAAyBoAsAAAAAAACGQNAFAAAAAAAA +QyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCARdAAAAAAAAMASCLgAAAAAAABgCQRcAAAAA +AAAMgaALAAAAAAAAhkDQBQAAAAAAAEMg6AIAAAAAAIAhEHQBAAAAAADAEAi6AAAAAAAAYAgEXQAA +AAAAADAEgi4AAAAAAAAYAkEXAAAAAAAADIGgCwAAAAAAAIZA0AUAAAAAAABDIOgCAAAAAACAIRB0 +AQAAAAAAwBAIugAAAAAAAGAIBF0AAAAAAAAwBIIuAAAAAAAAGAJBFwAAAAAAAAyBoAsAAAAAAACG +QNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCARdAAAAAAAAMASCLgAAAAAA +ABgCQRcAAAAAAAAMgaALAAAAAAAAhkDQBQAAAAAAAEMg6AIAAAAAAIAhEHQBAAAAAADAEAi6AAAA +AAAAYAgEXQAAAAAAADAEgi4AAAAAAAAYAkEXAAAAAAAADIGgCwAAAAAAAIZA0AUAAAAAAABDIOgC +AAAAAACAIRB0AQAAAAAAwBAIugAAAAAAAGAIBF0AAAAAAAAwBIIuAAAAAAAAGAJBFwAAAAAAAAyB +oAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCP83AL6WQ1Y7 +46xVAAAAAElFTkSuQmCC diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/img/logo.png b/uncloud_django_based/uncloud/static/uncloud_pay/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b82326f5a4af6bf6f9ab15a7f6ba0c035f6a2a5c GIT binary patch literal 28401 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 literal 0 HcmV?d00001 diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/img/msg.png b/uncloud_django_based/uncloud/static/uncloud_pay/img/msg.png new file mode 100644 index 0000000000000000000000000000000000000000..3b7b0c70b77b0b5ebde9f9be8d18b80c41223cb2 GIT binary patch literal 4654 zcmW+&byQSs6Tb-3-H0^O-5|ZBl=MO%O$`+TbXG)%6fQRUsY843iB5R#8fIPqAi(xt zVEC6P_yPdF-Ww3;**#Q9jmvRlEMuxl8au=(o%39H( zsakFZRJhCr%5kBTNgO;Zc%(0)Sqc`Z^t;GZRk7#?^P@2n5<=st^!W&5@fUD5SPB!u zi=tzPZ+-_AInA|QY>nMF&C73BpX4@9;2>}b(=|l(#X|`zlo`o>g%9-h|K1Q#48v!4 z2S{-n?OD9;*f4<05LsDXRs>E5fZ;zwfD0gWau7mKqaH~1l(Q@`LO)_4ypyD~a6>5p z#lQsRQb17+BQ!UY!w|^F0<1^uY}bL8{D3v*`>h!uH1{$q2m`Q4Wud~zPXd@J9HLbK z$LB!xgh{L_02Tr$AzHoCzyd!Yq-hG#0KPQ?h;dSa1^|~35Yms1;0CaQ0P8_EHhH5A_sraVd<`1V#FBPfZ#+{zB~T#J(w?2TBv0D$9G zx2~UDxR{}i;lC&R9{wuaspmffLLr*T9sux4g`VS`vB zW@8P8V>!=9(ovRbElJM@0g(^4k!|Y2sbLNJaz6>1K=5+OCm+=c2`25mg|ic>^K&LW zzdwRGD^tNNdOGpWXGjXATM zsQ1Gn4^u_pNnrUYGutpKX@IrCx(gcR%um_H$d>RV@i0H?cXP6)lxuzGXd%1mrL`3i zjsk1&vCWYuQV5a;MT2@J*~w!-BAoEgk2LFxji$+`)uvqy*mn6Ua>O*Au%!$w**i7h zNT>2K^A1LAg^P{cH)Gno)iK81Z5|R|&&)m);$#OFW^T2rCQ6&xI zr@ctyNTcTwG^(g9tz4}nFg`IJF-oj>Wf)wmW-MyBUV-;By~?;U_vOAJy&<&Xd3jVN zwA{@|p*Y0sE(Y7IN4rN4RIHz0|9ZWxpP9gpuF;>5#fhjY(kD#3m!p1c@trSEYvvt8 zYn)tVMS(O(^o6KtqrpikL;+_#)8q1sw=Ui`S6RkMNbD-*U+y(-`cg4%xUhqzQ)PNa zI=D)sO20~dzg?pCpe(nvUA5KhCX85V;0328r#duo{BiEK;iBP68GaeHj-1R{tw5zy z34KAUieIN|*v zE^Sro|t;(B&_Ix{n4Z$j!>Ar0)U?Xc`F0f!5s4We& zxv#e8Fr_^uxFz`0og5BP)FN>egE>Qp*LP1!855b+?C~#$3c-D+>AQkZ@C50^g6KMo zVuZW*O|D_ij5?GC3ik>>iH?iW`YMS zP~z~-Vbt>1QL(Swd1j(slRbl=U;3L<3It!%gq%>-A$lOaFlf^@o1mRwl4;HSx8@SR z$_tynH1g;2o?qAH*JUMFMOMvTk4hq%2b)Lqdff6}N1#CO*&gg40w1P;ve0K3P|TPx zr7(*}H`DD@+wWD>VQvWHD@|07@KRc%IdzOG7(9O^dWfh!~n2KHiS@JOI9&7OE1SbDU zY7-A&?-MQI+k}7QtoYofO{Be&Ba=s+?aS;UA}7->9G;x?*+aRKMJELY-GgpHd`GGs zA{y@dT;rMr8PjlvoEkq@KQ`mXhbfot7@2d;^WZc<9ZVseV==?h=~a2WV+W&+!^qE) zU;ci5I3VvP|D6mgiPSRXX8>=tSHG2Rko5>a;;&OZ=8ZJLu9p1~3*&K_)vG!%&8Q7& z=Lpns3VYQx{GqD}!)c$u7=JS&cz=2Rz)BA+XohOCxsJ@Gm#2z}t))cLWKg|d;9BpkN?#x6QNALADo@5A_X(XxR9;k zFc7?`Q}#!jL)p;y*mDJ=Vxv=|Ya@xn>6Ip%`AgTSL;9e$bFn|ONQ<_MJNcmwq%nrE z&Mais;YRbO@j{_CrgHZ({l>+rM%TsTU;uL(^A3lN7&Z_8Im<9DXJn8#$GfjiJiyd( zpZnL3pGbnp_|upEH8yvZfQ{V?Jk$jvk<5~Bd{FgooEaux1}KBzTjlKIY_%N2>>CSP zix$06`Kh_R)xcYIBooOOF9xH+ zRXk8y1g%8PDp=evFZ^A8{}$nwe=@bKet{hkK5?IPmPi76oS2n35=j)fpC=_RExo4T zdAEC82AZ&+;K-x7FT5YArJ)uKxt!gfAD_ycD#$p_Na=KWS8}(9*r%&Q_ILL)b`1H8 z3corvxjE?aTlQo4TZAIf+jz(Pp6kK!s{YJyvg{^>E+ypw3p4ZN)=rckiN0?#+i4i+ z06^d~00<2SfU5^|-UR?3ApqF31^}5%0HAS8e%+@A061ovDoRHFOMkL0G=3s!1{69+ zEWgwF^p7~k#AvfnaVeQ`oqbjzEzu;6u!dlnkv&Tf`vvl5)5c}$E-!euqpXi-^|?EP zYpf7~+A6VpQwAf~gvJ5%;WHXwOx3!=?oxFAE4zc15eO{!11 zSZ{|DbaD8MoBPcgApibe+scZ8pPygqX~KiHwo`UL+-o~JHrDpx{w{NDD+a^=_EH%A z$IQ&kPC~|74%|l{n3-c)spBF_OF2wx%=?DZcsa52+uL(%YX#QU)|4qS#~@frxLQDE zWnm>xOD)@o$lcxD<=uTu6oRA>2oDdpheEyfesYsjQ1p-^+Ji32Yis-W&mvdf8_fJl zq_P&t>XsnVwfBi##}Kkh%V);L3RhB7Q|n&JiaGV|$3i$uoNSHJS5{T+?(N|}ANVR8 zpd1`5hw6OK)NDdGGt&O zoRL0c&q_;pyfel2PM*mE=FFm?z!cu~uTE+eg-RURT-%$i@^60?ueSbik1{~^=-G%! zcInYtAAu|*31ecE3lgRi^w!zUZF{;zYq`aTKxZbT;yi>tlo^*R_Cq08rZ&eYL=@E# zTm*xq3%>4nX<(on7kN^jmvm+4;lVdH=43)7vT{jE(qxRNnwU^0B_$OM zRh#g(x3{NeWOyB|skXfmOz~Jn1C6GL-9UoJ_30aSO;%RcM>;c;Vnk6rUFQc#X`ekx zIy$=OLaD=mz9Z)kA)QP)W0519h57kFTpxM%q_vQ>5N&xy#qXV|B5!Z+_#3C&;8}4= zNkbEp{qBC^9ekV`NBtLq9 zY(~#PDVZHh35}3-lQ}m(|7LFt#lpe4ChvGiYaeec>(SBC7iq7^wKZFNGY(Q*wA9ew zLZS4~6vrW9EG{ZqFI)c(gBh5c4;QH?zNCDtPf3P_@pVGo)|NFbEsc_j%BCL7A>N~H zKCP>vVSRD9Qe0BS}~epbuyXvC*F7 z;^Kl<%M*I~n1lqic^~B5tGCwoZ(-v^D(Bi|Zs;bJ) z&aPeYJ+DFSy_y;}x;Uv^-C9eyG8D>*26y8hDq?POk`x32p-sjR3?6Lp+2@d3Eu#Vy zJv~Kk&JWnc#nU1qv6q&XE>Cw2%U$Pv7|@!FjztHn@U$pem9igSYHJTMZvuocMOI7` z=Hf57#)O=lywsuMVIR16=H})uE-t?HYw3RP8vn6M=X9limYe`PoGX?*9pEqzNPzRo z3JEDXIq?!lvHf8lY=}o5K)k&vl}P(%DK5D~nFg#|YV9O)0xk~G^%~pIV`rBZRPr?N ziOR^t*6!OqIx+;b()n1##ix$_RAW0{Ea(8wdJv}`a z+5$x3aJbzxO?RDZ+|6Ccva2N82R`2Pc@|A+7#NVBY)_~s7uQFO{qeb?bN&AwdzATD hh`^c}K?qa82K=#ax-GS2(fb$xG*xv~YL%@&{15VSx)}ff literal 0 HcmV?d00001 diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/img/twitter.png b/uncloud_django_based/uncloud/static/uncloud_pay/img/twitter.png new file mode 100644 index 0000000000000000000000000000000000000000..4db6da08d64c20d74b5d17052126bcaf6bc12b23 GIT binary patch literal 4821 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 literal 0 HcmV?d00001 diff --git a/uncloud_django_based/uncloud/uncloud/settings.py b/uncloud_django_based/uncloud/uncloud/settings.py index 5b4744d..94476d1 100644 --- a/uncloud_django_based/uncloud/uncloud/settings.py +++ b/uncloud_django_based/uncloud/uncloud/settings.py @@ -172,5 +172,5 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.0/howto/static-files/ - STATIC_URL = '/static/' +STATICFILES_DIRS = [ os.path.join(BASE_DIR, "static") ] diff --git a/uncloud_django_based/uncloud/uncloud/urls.py b/uncloud_django_based/uncloud/uncloud/urls.py index a848dff..8de3fa5 100644 --- a/uncloud_django_based/uncloud/uncloud/urls.py +++ b/uncloud_django_based/uncloud/uncloud/urls.py @@ -13,8 +13,12 @@ Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.contrib import admin from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static + from rest_framework import routers @@ -60,7 +64,12 @@ router.register(r'admin/opennebula', oneviews.VMViewSet, basename='opennebula') router.register(r'user', authviews.UserViewSet, basename='user') +# Testing +# router.register(r'user', authviews.UserViewSet, basename='user') +from uncloud_net import views as netview + urlpatterns = [ path('', include(router.urls)), + path('pdf/', payviews.MyPDFView.as_view(), name='pdf'), path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) # for login to REST API ] diff --git a/uncloud_django_based/uncloud/uncloud_pay/templates/bill.html b/uncloud_django_based/uncloud/uncloud_pay/templates/bill.html new file mode 100644 index 0000000..ab29158 --- /dev/null +++ b/uncloud_django_based/uncloud/uncloud_pay/templates/bill.html @@ -0,0 +1,815 @@ +{% load static %} + + + + + + + Bill name + + + + + + + + + +

+
+ ungleich glarus ag +
Bahnhofstrasse 1 +
8783 Linthal +
Switzerland +
+
+
+ Faeh+Faeh GmbH +
Pascal Faeh + <pascal@faehundfaeh.ch> +
Via Nova +
7017 Flims +
+
+
+
+ Rechnungsdatum: +
Rechnungsnummer +
Zahlbar bis + +
+
+ 2018-04-21
+ 20180421FAEH1
+ 2018-05-20 +
+
+
+
+

RECHNUNG

+
+
+

+ Beschreibung + Netto CHF +

+
+
+

+ NAS Synology DS1817+ + 1234.56 +

+

+ 10Gbit/s card Synology E10G17-F2 + 345.67 +

+ + +

+ 1OGbit/s switch HP + 567.89 +

+

+ Festplatten 10 TB NAS RED Pro + 3456.78 +

+

+ 10Gbit/s Transceiver Synology + 123.45 +

+

+ 10Gbit/s Transceiver Switch kompatibel + 123.45 +

+

+ NAS Synology DS1817+ + 1234.56 +

+

+ 10Gbit/s card Synology E10G17-F2 + 345.67 +

+ + +

+ 1OGbit/s switch HP + 567.89 +

+

+ Festplatten 10 TB NAS RED Pro + 3456.78 +

+

+ 10Gbit/s Transceiver Synology + 123.45 +

+

+ 10Gbit/s Transceiver Switch kompatibel + 123.45 +

+

+ 10Gbit/s Transceiver Switch kompatibel + 123.45 +

+

+ 10Gbit/s Transceiver Switch kompatibel + 123.45 +

+

+ 10Gbit/s Transceiver Switch kompatibel + 123.45 +

+

+ 10Gbit/s Transceiver Switch kompatibel + 123.45 +

+

+ 10Gbit/s Transceiver Switch kompatibel + 123.45 +

+

+ 10Gbit/s Transceiver Switch kompatibel + 123.45 +

+

+ 10Gbit/s Transceiver Switch kompatibel + 123.45 +

+ +
+
+

+ Total + 12345.67 +

+

+ 7.70% Mehrwertsteuer + 891.00 +

+
+
+

+ Gesamtbetrag + 23456.78 +

+
+ + + + diff --git a/uncloud_django_based/uncloud/uncloud_pay/templates/bill.html.template b/uncloud_django_based/uncloud/uncloud_pay/templates/bill.html.template new file mode 100644 index 0000000..019ee81 --- /dev/null +++ b/uncloud_django_based/uncloud/uncloud_pay/templates/bill.html.template @@ -0,0 +1,101 @@ + + + + + + ungleich + + + + + + +
+ +
+
+ ungleich glarus ag +
Bahnhofstrasse 1 +
8783 Linthal +
Switzerland +
+
+
+ $company_name +
$user_name + $user_email +
$user_street +
$user_postal $user_city +
$user_country +
+
+
+ Rechnungsdatum: +
Rechnungsnummer +
Zahlbar bis + +
+
+ $invoice_date
+ $invoice_number
+ $invoice_payable_on +
+
+
+
+

RECHNUNG

+
+
+

+ Beschreibung + Netto CHF +

+
+
+ $product_names_and_amounts +
+
+

+ Total + $total_amount +

+

+ 7.70% Mehrwertsteuer + $total_vat_amount +

+
+
+

+ Gesamtbetrag + $grand_total +

+
+ + + + \ No newline at end of file diff --git a/uncloud_django_based/uncloud/uncloud_pay/views.py b/uncloud_django_based/uncloud/uncloud_pay/views.py index e86a464..255f113 100644 --- a/uncloud_django_based/uncloud/uncloud_pay/views.py +++ b/uncloud_django_based/uncloud/uncloud_pay/views.py @@ -148,3 +148,12 @@ class AdminOrderViewSet(viewsets.ModelViewSet): def get_queryset(self): return Order.objects.all() + +# PDF tests +from django.views.generic import TemplateView +from hardcopy.views import PDFViewMixin, PNGViewMixin + +class MyPDFView(PDFViewMixin, TemplateView): + template_name = "bill.html" + # def get_filename(self): + # return "my_file_{}.pdf".format(now().strftime('Y-m-d')) From 8fb3ad7fe8dffb7a0086ae8350438f48cad40d2e Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 3 Apr 2020 18:51:09 +0200 Subject: [PATCH 266/284] inline all pictures --- .../uncloud_pay/css/font/Avenir-Regular.woff2 | Bin 23476 -> 0 bytes .../uncloud_pay/css/font/AvenirLTStd-Book.otf | Bin 27444 -> 0 bytes .../static/uncloud_pay/css/font/avenir-base64 | 1 - .../static/uncloud_pay/css/font/font.css | 0 .../uncloud/static/uncloud_pay/css/font/foo | Bin 27444 -> 0 bytes .../uncloud_pay/css/font/regular-base64 | 1 - .../uncloud/static/uncloud_pay/css/style.css | 115 - .../uncloud/static/uncloud_pay/img/call.png | Bin 3507 -> 0 bytes .../uncloud/static/uncloud_pay/img/home.png | Bin 3643 -> 0 bytes .../static/uncloud_pay/img/logo-base64 | 499 ----- .../uncloud/static/uncloud_pay/img/logo.png | Bin 28401 -> 0 bytes .../uncloud/static/uncloud_pay/img/msg.png | Bin 4654 -> 0 bytes .../static/uncloud_pay/img/twitter.png | Bin 4821 -> 0 bytes .../uncloud/uncloud_pay/templates/bill.html | 1943 ++++++++++------- 14 files changed, 1128 insertions(+), 1431 deletions(-) delete mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/css/font/Avenir-Regular.woff2 delete mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/css/font/AvenirLTStd-Book.otf delete mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/css/font/avenir-base64 delete mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/css/font/font.css delete mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/css/font/foo delete mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/css/font/regular-base64 delete mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/css/style.css delete mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/img/call.png delete mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/img/home.png delete mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/img/logo-base64 delete mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/img/logo.png delete mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/img/msg.png delete mode 100644 uncloud_django_based/uncloud/static/uncloud_pay/img/twitter.png diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/css/font/Avenir-Regular.woff2 b/uncloud_django_based/uncloud/static/uncloud_pay/css/font/Avenir-Regular.woff2 deleted file mode 100644 index be2045c9538483daf3979cfe227e02264f0f1a8a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23476 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 diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/css/font/AvenirLTStd-Book.otf b/uncloud_django_based/uncloud/static/uncloud_pay/css/font/AvenirLTStd-Book.otf deleted file mode 100644 index 52ab53e85d7b38c94d6a76a41b43042fd9d9eea1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27444 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 V-?s3CmBMXbZ}l&9S`M6B1pxAc>0AH+ diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/css/font/avenir-base64 b/uncloud_django_based/uncloud/static/uncloud_pay/css/font/avenir-base64 deleted file mode 100644 index 315f277..0000000 --- a/uncloud_django_based/uncloud/static/uncloud_pay/css/font/avenir-base64 +++ /dev/null @@ -1 +0,0 @@ -T1RUTwANAIAAAwBQQkFTRT9iT7oAAFZoAAAANENGRiAEWUuJAAAMrAAAPC5EU0lHmRKQTgAAVpwAABSYR1BPUwasDVsAAE/8AAAGbEdTVUJt6HATAABM0AAAAyxPUy8yeggQQwAAAUAAAABgY21hcH7oTiAAAAjoAAADpGhlYWTUopyKAAAA3AAAADZoaGVhBwADSwAAARQAAAAkaG10eAzVKxoAAEjcAAAD9G1heHAA/VAAAAABOAAAAAZuYW1l7sVYggAAAaAAAAdGcG9zdP+4ADIAAAyMAAAAIAABAAAAAQdsjRKTBF8PPPUAAwPoAAAAALklKOMAAAAAuSUo4/9Z/uAD6AOYAAAAAwACAAAAAAAAAAEAAAL0/wwAyAPo/1n/WQPoAAEAAAAAAAAAAAAAAAAAAAD9AABQAAD9AAAAAgISAV4ABQAEAooCWAAAAEsCigJYAAABXgAyARgAAAILBQICAgMCAgSAAACvQAAgSgAAAAAAAAAAQURCRQAAACD7AgL0/wwAyAOYASAAAAABAAAAAAHUAsQAIAAgAAQAAAAYASYAAQAAAAAAAACWAAAAAQAAAAAAAQANAJYAAQAAAAAAAgAHAKMAAQAAAAAAAwAbAKoAAQAAAAAABAAVAMUAAQAAAAAABQA0ANoAAQAAAAAABgAQAQ4AAQAAAAAABwCcAR4AAQAAAAAACQAPAboAAQAAAAAACwAZAckAAQAAAAAADgAkAeIAAwABBAkAAAEsAgYAAwABBAkAAQAqAzIAAwABBAkAAgAOA1wAAwABBAkAAwA2A2oAAwABBAkABAAgA6AAAwABBAkABQBoA8AAAwABBAkABgAgA6AAAwABBAkABwE4BCgAAwABBAkACQAeBWAAAwABBAkACwAyBX4AAwABBAkADgBIBbAAAwABBAkAEAAaBfgAAwABBAkAEQAOBhJDb3B5cmlnaHQgqSAxOTg5LCAxOTk1LCAyMDAyIEFkb2JlIFN5c3RlbXMgSW5jb3Jwb3JhdGVkLiAgQWxsIFJpZ2h0cyBSZXNlcnZlZC4gqSAxOTgxLCAxOTk1LCAyMDAyIEhlaWRlbGJlcmdlciBEcnVja21hc2NoaW5lbiBBRy4gQWxsIHJpZ2h0cyByZXNlcnZlZC5BdmVuaXIgTFQgU3RkNDUgQm9vazEuMDI5O0FEQkU7QXZlbmlyTFRTdGQtQm9va0F2ZW5pciBMVCBTdGQgNDUgQm9va09URiAxLjAyOTtQUyAwMDEuMDAxO0NvcmUgMS4wLjMzO21ha2VvdGYubGliMS40LjE1ODVBdmVuaXJMVFN0ZC1Cb29rQXZlbmlyIGlzIGEgdHJhZGVtYXJrIG9mIEhlaWRlbGJlcmdlciBEcnVja21hc2NoaW5lbiBBRywgZXhjbHVzaXZlbHkgbGljZW5zZWQgdGhyb3VnaCBMaW5vdHlwZSBMaWJyYXJ5IEdtYkgsIGFuZCBtYXkgYmUgcmVnaXN0ZXJlZCBpbiBjZXJ0YWluIGp1cmlzZGljdGlvbnMuQWRyaWFuIEZydXRpZ2VyaHR0cDovL3d3dy5hZG9iZS5jb20vdHlwZWh0dHA6Ly93d3cuYWRvYmUuY29tL3R5cGUvbGVnYWwuaHRtbABDAG8AcAB5AHIAaQBnAGgAdAAgAKkAIAAxADkAOAA5ACwAIAAxADkAOQA1ACwAIAAyADAAMAAyACAAQQBkAG8AYgBlACAAUwB5AHMAdABlAG0AcwAgAEkAbgBjAG8AcgBwAG8AcgBhAHQAZQBkAC4AIAAgAEEAbABsACAAUgBpAGcAaAB0AHMAIABSAGUAcwBlAHIAdgBlAGQALgAgAKkAIAAxADkAOAAxACwAIAAxADkAOQA1ACwAIAAyADAAMAAyACAASABlAGkAZABlAGwAYgBlAHIAZwBlAHIAIABEAHIAdQBjAGsAbQBhAHMAYwBoAGkAbgBlAG4AIABBAEcALgAgAEEAbABsACAAcgBpAGcAaAB0AHMAIAByAGUAcwBlAHIAdgBlAGQALgBBAHYAZQBuAGkAcgAgAEwAVAAgAFMAdABkACAANAA1ACAAQgBvAG8AawBSAGUAZwB1AGwAYQByADEALgAwADIAOQA7AEEARABCAEUAOwBBAHYAZQBuAGkAcgBMAFQAUwB0AGQALQBCAG8AbwBrAEEAdgBlAG4AaQByAEwAVABTAHQAZAAtAEIAbwBvAGsATwBUAEYAIAAxAC4AMAAyADkAOwBQAFMAIAAwADAAMQAuADAAMAAxADsAQwBvAHIAZQAgADEALgAwAC4AMwAzADsAbQBhAGsAZQBvAHQAZgAuAGwAaQBiADEALgA0AC4AMQA1ADgANQBBAHYAZQBuAGkAcgAgAGkAcwAgAGEAIAB0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b3Jwb3JhdGVkLiBBbGwgUmlnaHRzIFJlc2VydmVkLiBBdmVuaXIgaXMgYSB0cmFkZW1hcmsgb2YgSGVpZGVsYmVyZ2VyIERydWNrbWFzY2hpbmVuIEFHLCBleGNsdXNpdmVseSBsaWNlbnNlZCB0aHJvdWdoIExpbm90eXBlIExpYnJhcnkgR21iSCwgYW5kIG1heSBiZSByZWdpc3RlcmVkIGluIGNlcnRhaW4ganVyaXNkaWN0aW9ucy4gVGhlIGRpZ2l0YWxseSBlbmNvZGVkIG1hY2hpbmUgcmVhZGFibGUgc29mdHdhcmUgZm9yIHByb2R1Y2luZyB0aGUKIFR5cGVmYWNlcyBsaWNlbnNlZCB0byB5b3UgaXMgY29weXJpZ2h0ZWQgKGMpIDE5ODksIDE5OTUsIDIwMDIgQWRvYmUgU3lzdGVtcy4KIEFsbCBSaWdodHMgUmVzZXJ2ZWQuIFRoaXMgc29mdHdhcmUgaXMgdGhlIHByb3BlcnR5IG9mIEFkb2JlIFN5c3RlbXMKIEluY29ycG9yYXRlZCBhbmQgaXRzIGxpY2Vuc29ycywgYW5kIG1heSBub3QgYmUgcmVwcm9kdWNlZCwgdXNlZCwgCiBkaXNwbGF5ZWQsIG1vZGlmaWVkLCBkaXNjbG9zZWQgb3IgdHJhbnNmZXJyZWQgd2l0aG91dCB0aGUgZXhwcmVzcyAKIHdyaXR0ZW4gYXBwcm92YWwgb2YgQWRvYmUuCiAKIFRoZSBkaWdpdGFsbHkgZW5jb2RlZCBtYWNoaW5lIHJlYWRhYmxlIG91dGxpbmUgZGF0YSBmb3IgcHJvZHVjaW5nIHRoZSBUeXBlZmFjZXMgCiBwcm92aWRlZCBhcyBwYXJ0IG9mIHlvdXIgbGFzZXIgcHJpbnRlciBpcyBjb3B5cmlnaHRlZCAoYykgMTk4MSwgMTk5NSwgMjAwMiBIZWlkZWxiZXJnZXIgRHJ1Y2ttYXNjaGluZW4gQUcuIAogQWxsIHJpZ2h0cyByZXNlcnZlZC4gVGhpcyBkYXRhIGlzIHRoZSBwcm9wZXJ0eSBvZiBIZWlkZWxiZXJnZXIgRHJ1Y2ttYXNjaGluZW4gQUcsIAogYW5kIG1heSBub3QgYmUgcmVwcm9kdWNlZCwgdXNlZCwgZGlzcGxheWVkLCBtb2RpZmllZCwgZGlz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ACYANgA+AEYAVgBeAGYAbgABAAAAAQFaAAMAAAABAWgABgAAAAUAUABmAHoAjgCiAAQAAAABAKYABAAAAAEAsAAGAAAABQC6AMoA3ADuAQIAAQAAAAEBBgAEAAAAAQE4AAQAAAABAZYAAQAAAAEBrAADAAAABAGuAbQBrgGuAAAAAQAAAAcAAwAAAAMBmAGeAZgAAAABAAAACAADAAAAAwGSAYoBmAAAAAEAAAAHAAMAAAADAX4BdgGKAAAAAQAAAAcAAwAAAAMBfAFiAXAAAAABAAAABwABAW4AAQAIAAEABABtAAIASgABAVwAAQAIAAEABABuAAIATQADAAIBMgEyAAEBUAAAAAAAAwABAUYAAQFAAAAAAQAAAAkAAwABATQAAQE+AAAAAQAAAAkAAwACASIBMgABARwAAAABAAAACQADAAIBDgEeAAEBGAAAAAEAAAAJAAIBEAAHAJYApACpAIsAjwCLAI8AAgEOAAgAeQCWAKQAqQCLAI8AiwCPAAEBDAABAAgAAwBvAIkApgABAQIAAwAMACYAUAACAAYAEAB6AAQAEAARABEAegAEAGMAEQARAAQACgASABoAIgCbAAMAEAATAJ4AAwAQABUAmwADAGMAEwCeAAMAYwAVAAIABgAOAKMAAwAQABUAowADAGMAFQABACgAAQAIAAIABgAOAAYAAwAQABEABgADAGMAEQACAIgAAgCLAI8AAQABABEAAQACABAAYwABAAEAEgABAAEAFQABAAEAEwABAAEAFAABAAEARwABAAEAQgACAAEAEQAaAAAAAQABAFAAAQABAA8AAQAHABIAEwAUACIAMABCAFAAAQAIAA8AEgATABQAIgAwAEIAUAABAAEADgABAAMAEQASABQAAQACAEIAUAABAAAACgAeACwAAWxhdG4ACAAEAAAAAP//AAEAAAABa2VybgAIAAAAAQAAAAEABAACAAAAAgAKALAAAQRYAAQAAAAGABYAHABaAIQAlgCgAAEACP/cAA8Ayf/IAMr/5ADL/8gAzP/IAM3/yADQ/60A0f/IANL/rQDT//wA1AABANYABADa/8gA2//IAN3/5ADg/9IACgDJ/+QAyv/kAMv/5ADM/+QAzf/kAND/5ADR/+QA0v/kANn/1wDa/+QABADK/+QA0P/yANr/8gDg//IAAgDa/7sA4P/IAAEAQf/c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 diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/css/font/font.css b/uncloud_django_based/uncloud/static/uncloud_pay/css/font/font.css deleted file mode 100644 index e69de29..0000000 diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/css/font/foo b/uncloud_django_based/uncloud/static/uncloud_pay/css/font/foo deleted file mode 100644 index 52ab53e85d7b38c94d6a76a41b43042fd9d9eea1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27444 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 V-?s3CmBMXbZ}l&9S`M6B1pxAc>0AH+ diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/css/font/regular-base64 b/uncloud_django_based/uncloud/static/uncloud_pay/css/font/regular-base64 deleted file mode 100644 index 1e98cef..0000000 --- a/uncloud_django_based/uncloud/static/uncloud_pay/css/font/regular-base64 +++ /dev/null @@ -1 +0,0 @@ -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 diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/css/style.css b/uncloud_django_based/uncloud/static/uncloud_pay/css/style.css deleted file mode 100644 index 78852fb..0000000 --- a/uncloud_django_based/uncloud/static/uncloud_pay/css/style.css +++ /dev/null @@ -1,115 +0,0 @@ -body { - font-family: Avenir; - background: white; - padding: 20px; - font-weight: 500; - line-height: 1.1; - font-size: 14px; - width: 600px; - margin: auto; - padding-top: 40px; - padding-bottom: 15px; - -} -p { - display: block; - -webkit-margin-before: 14px; - -webkit-margin-after: 14px; - -webkit-margin-start: 0px; - -webkit-margin-end: 0px; -} -.bold { - font-weight: bold; -} -.logo { - width: 220px; - height: 120px; -} -.d1 { - width: 60%; - float: left; - -} -.d2 { - padding-top: 15px; - width: 40%; - float: left; -} -.d4 { - width: 40%; - float: left; -} -.b1 { - width: 50%; - float: left; -} -.b2 { - width: 50%; - float: left; - text-align: right; - left: 0; -} -.d5 { - margin-top: 50px; - width: 100%; -} -.d6 { - width: 60%; - float: left; - font-size: 13px; -} -.d7 { - width: 40%; - float: left; -} -.wf { - width: 100%; -} -hr { - border: 0; - clear:both; - display: inline-block; - width: 100%; - background-color:gray; - height: 1px; - } - .tl { - text-align: left; - } - - .tr { - text-align: right; - float: right; - } - .pc p { - display: block; - -webkit-margin-before: 3px; - -webkit-margin-after: 5px; - -webkit-margin-start: 0px; - -webkit-margin-end: 0px; -} - .th { - border-top: 1px solid gray; - border-bottom: 1px solid gray; - } - .ts { - font-size: 14px; - } - .icon { - width: 16px; - height: 14px; - vertical-align: middle; - margin-right: 2px; - } - .footer { - margin-top: 70px; - font-size: 14px; - } - - .footer p { - display: block; - -webkit-margin-before: 5px; - -webkit-margin-after: 5px; - -webkit-margin-start: 0px; - -webkit-margin-end: 0px; -} \ No newline at end of file diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/img/call.png b/uncloud_django_based/uncloud/static/uncloud_pay/img/call.png deleted file mode 100644 index e774362528ae31636b9136ba2ff6441b2b5e7b6d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3507 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 diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/img/home.png b/uncloud_django_based/uncloud/static/uncloud_pay/img/home.png deleted file mode 100644 index 24428e7695bac0907de67679cc392972ba7353b3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3643 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 N002ovPDHLkV1m+}xikO( diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/img/logo-base64 b/uncloud_django_based/uncloud/static/uncloud_pay/img/logo-base64 deleted file mode 100644 index d2d520b..0000000 --- a/uncloud_django_based/uncloud/static/uncloud_pay/img/logo-base64 +++ /dev/null @@ -1,499 +0,0 @@ -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 -Rw8f1hPPPKtBpw0Jufa8fcsWffzBe1q5bKnKSksNcY/aw+2sYtpA7XnCp59q+uRJSj92tN7ve3UN -PA7s26cjh97UimVLdPPtd+rSK65slqsRbtqwXhO/nKA1K1cqJ/t4s20X5WVlWrt6lWZOn6ad27Yq -Lze3zkPAa8vr8fzYQ2z3zp2aNeNbDR0+Qjf86hadPmJESLYHm81a66GLSxYu0Kcfvq8d27b97NDO -+vC43co+nqXs41nauG6dFi+Yr3seeEhdunXjTQ4AADQ6gq4mVFVZqc2bNmrW9OnatGG9so9nBWW4 -238+mLPqYuCqq6uVn5f3yw9EXq++nTJZ7/zjTR07eqTRh4j6/X5t2bRRzzzxuJ7/6ysh0+vA6XRq -0lcT9MHbbysn+7ihFkTgXgpyqOD1at2a1frXG69r2+ZNctehV0mwud1ubdu8WWkHDmjLxg26/3cP -q2Wr1qFflx6PNm/coMnffK1Vy5epID+/3r2dmvIaLF4wX9988bl2bt+u0tKSJnkf8Xq9ysvN1ZyZ -M7R6+XKNPvts3XXf/erVp29IvRdERkYpMjLyF7c5cviQPnznbc2eOUPlZWUNfkylpSWaMXWKdm7f -pt/94VGdd9HFzTI0BgAAzRdBVyOHE1WVlTqelaXVK5dr+uTJ2pe6R263mxUSQ+yhseQXhosWFRbq -rddf0+Svv6zVnF8Nac+unfrzE4/p1X/8Uz179W7SY8nNydHbb76ub76c0GDDN2EMJSXF+mbCF3rv -rX+GZI+/stJSff3F5zqwf78e/9Mz6jdgQEgGnVWVldq+das+/+QjrVy+TM6qqmb7WfJDWPfev97S -2lUrQyL4/M/2OmvGt1q5fJluvv0OXf+rm0Nmrj6//Ce95k6nU8sWL9I/X/ubDu7f36jhp8/n08H9 -+/XkH36vgvx8jb/hRtntdt78AABAoyDoaugvoX6/KisqlJOTrd07d2r5ksVatXxZjT2G0LTKy8vl -9/t/8nDr9/u1N3WP/v7SX7Ri6ZKQeaDcs2un/vG3V/XcX19Ry1atmuQY0o8e1csvPKf5c2bTePCL -MtLT9ebfXtG3UyaH9HH6fD5tWLtGf3jwfj357HMaffbYkBm6WlJSrG2bN2vyN19r2aKFQe0J3BSf -kUcOH9KXn32qSV9/pcqKipA9zuKiIr31+mtau2qV7n3otxox6kyFhYU16XHZ7eEKs//vMRzPytIX -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 -Ul1d3WCBwd7UPfrrs89ozaqVtQrhktu0Ua8+fZSRnq683FyVlpTI6/U2m3ts/uxZuuKqq9W3/4BG -fd2wsDCNv+4G9ezVWx+9+44WzJ3TKGG23W5Xl27dNWr0GJ151tnq1qNHg75efEKCLrj4Eg0dNlw3 -3nKrZkybqlXLlykvN7fRr3V0TIwGDBqsy64cp0FDhqh9SoeQXPjDSOITEnT9r27W8JEjNW/2LE2b -ODFoiyAEg8Ph0Jix5+j6X92sAYMGKzomhosGAAAQwuoXdJ0Kqxw2k3OMjIrSxZdfoUFDhmr+nNla -smC+tm7ZrKrKynrv22QyKSo6WkNOH6aLL7tcw0eOUnKbNgE/sPfp11+5OdmKiPjlnl0ul0s9+/SR -LYjDg1q1aq1+AwepvKxMDoejxnDG5/PK7faoc9duQT2OOj14R8dowKBBapGYKLvdXqtgqbq6Wr37 -9JXDEfweSNs2b9afHn9Uqbt31Wr70WeP1QMPP6JOnbvI6XQqKzNDRw8fVn5+nooLi+T1eeXzelVU -VCSXyyVzHec9MpvNqq6uVklJsYoKC1VUWKjy8nJVO531DtWyMjO1fetW9enXv0nmZerbf4Cee/kV -XX7VeE2b9I02rl+nosLCoAWZYWFhsoeHq1efvurTr59GjDxTffr1VWxcvMIbcbGPhBYtNGrMWRow -aLAyMtK1bvVqbVy3Vtu3blVpSbGcTmfQezfa7XY5HA71GzhIQ4cN1+kjRqhT5y5qkZjYrD6qPJ7A -27jP7wup3qIdO3XWr+++V+dfdLEWzpurOTNn6tDBA6qqqmqS42nZqrVGn3W2Lr3ySvUdMLDWP+zU -lddXh2vo84VED8i6tCO/3y+fzysAAIBgq98TfCOvRtYkQnTOmJNJbtNGN992u64cf412bd+mNStX -auvmTcpIP6aqqio5q6pUXV190i+lJpNJ4Q7Hjz2wOnTqpGFnnKHhZ4xSpy6dFRUVLbM58MU6I6Oi -dNd99+mWO+6Q2Wyp8Yt7uCM84KGOv2TYGSPVq09f+f1+WSyWWn0p9/m8Cnc4gnocddG5Sxe9+Orf -5Xa7Azh2n2w2m2JiY4N6LNs2b9aTf3xE+/furXFbR0SEbr3zLt1656+V1LLlT9rooNOGyOf1yuv7 -/wdtn9db74duv98vj8ej8vJyVVZUKCf7uPbv3atNG9Zrx7Ztys/LrXPotXPbNpWNGxdQz8Rgio2N -09jzztPpw4cr/dhRrV21SqtXrtChtIMqKy1VdXW1nFVVv1iHFqtV4eHhcnx/j7dqnaxeffuqX/8B -6tK1m1I6dlREZGSjhls/JzomRr1691H37j10zfU3KCc7W4fSDmrb5s3auX2bMjMyVFlZIafTqWqn -s8ZebiaTSTabTWHfh1p2u11JLVupd79+GjT4NHXt3l3tO3RQRERks+29FWYPk8lkkt1ur9X21dXV -slptITehvtVmU+cuXXXHb+7RlVeP15aNG7Vk0cIfw92K8vIGe22z2ay4+Hi1T0nReRdepLPOPU8d -OnRstCHL1u9/VLHb7bW6Lh6vVzarNSSGUJq/P4bavnf8EM7RWxIAADQEk78eT5Zlr70qz6E0mUxm -w1WM3++TPF7ZzzlXEVeNl5rp5MMet1tuj0cFeXnas3uXsjIzlZudrcqqSrldLlWUl8vn9ysyMlL2 -8HBFOCLULiVF7dq3V8/efRQTG3viizSTL5/yDuzbp98/eL/27NpZ47bxCQl69Kk/adz4a2Rt4gcZ -r8cjj9erjPRj+vyTjzVr+nSVlBQHvJ8Ro0bp1TffCrg3Y4Odl9crt9ut4qJC7UtNVUZ6uo5nZqqi -okIVFeWqqqqSyWSS1WJRRGSk7PZwxcXHq227dmrdpo26dO2qmNg4Wb9/UK5LgN2478n+H69lZUWF -jhw+pPRjx5SXk6O8vNwTQ3W93hPDYn0+2axWRcfEyGQ2Kzw8XElJLdUiMVFt2rZVx86dFR0T22zO -vSaVFRVavHCBsrOyZLXVLryqrnaqdetknXfhRU0e5td03d1utwry87Vm5Qpt37pFhw+lKTMjQ2Wl -paqqrJTb7Q44wP4hFIyMilJ8QoLap3RQz969NXzkKA0afJrsdnujfu55PR6tW7tGe3bulNVqlamG -NmkySW7XiR8/Ro05q8GHF9fk4P79WjB3jiKjImvVEd7v88nr9arfgIEadsYZfMACAICgqlfQJb/f -+MMXTaYTfwziPy/3f1/6/3w4MhnonFF/TqdTT/3x95oxdUqN20ZGRekPTzypX912R8i1I5/Pp8lf -f6XXXn5JhQUFAZXt3rOn3nr/I3Xp1q3Z3dv/eU8b7d6u7Xua0d/X6vpR3tzqxOfzqaK8XAf279Px -rCzlHD+u48ezdDwzU2WlpfL6vCovK5fb7ZJJJ87N5/fJYrYoMipKYWFhioqOVqvWyWrXvr3atmun -Dp06qXPXbgoLC2vy+qjLdQyVa9icjx0AABhL/X6uNFgIdCogzEJdHiwnfTVBC+bMrnFbs9msK666 -WuOvvzEk25fZbNb462/Q7p079M2ELwKa28bpdMrldnFvc94hXw9GZjabFR0To8FDhv7k791ut5xV -VfJ6vSosLFB1dfWPdeL3+WQ2WxQXHydHRITs9vBaD/HkOtIGAQBA88N4NAC/6PChNE2dOLFWE0Kn -dOiocddcK4fDEbLnY7FYdP5FF2vR/PnKzcmufTmzRRazhQYBhCCbzfbjfE9x8fFUCAAAwCnMTBUA -OBmfz6flixdrb+qemt9MzGaNGDVKvfr0Dfnz6ty1mxISEgKuC5/fR6MAAAAAgBBG0AXgpAry87Vx -/Tp5PZ4at42IjNTAwac1+Yp9tREbG6uo6OiAylitVnp0AQAAAECII+gCcFJZmZlKO3CgVts6HA7F -J7RoFudVXV0tl6s6oDIJiYkhvTodAAAAAICgC8AvyM3JVmFh7VYntFissofbm8V5pR87pqKiooDK -tE9JUVR0FI0CAAAAAEIYQReAk6qsqJDLVbuVBt1ul5y1mLA+FKxfu1q52dkBlek/YKCioqJpFAAA -AAAQwgi6AJyU3++X3++v1baVFRU6dvRoyJ/TkcOHNG/Wd6qurv3QxZatWqlv/wGyWJijCwAAAABC -GUEXgJOyWK21DnecTqeWL16k/Ly8kD2fqqoqffD2v7R7166Ayo05+xz17NOHBgEAAAAAIY6gC8BJ -tWrVStG1XJ3Q7/dr88YNmjltaq17gTWmyooKvfm3V/TtlMm1WkXyB4lJSbrkiiuaxWqSAAAAAHCq -I+gCcFJx8QlKTGpZ6+2rqqr0+acfa8nCBSF1HsezsvT800/p848/qvWcYz+4cvw1Gn7GSBoDAAAA -ADQDBF0ATioxKUmdOncOqEzGsWN6/uknNWPa1IBDpWCrrKjQkoUL9cBdd2j6lElyu90BlR86bLhu -vPlWWW02GgMAAAAANANWqgDAySS0aKHBQ4Zq/pzZAU3enpmRoacf/YN2btuqq6+/Qd26dW+0sMjn -86mosFB7U/do1vTp+nbq5IADLklKbtNG9zz4kFI6dqQhAAAAAEAzYfKH4mQ6AELG4UNp+uNvH9S2 -zZvrVL5T5y66+rrrdPqIM9S9R085IiKCvnqh1+uVs6pKx44e0d49e7R08SKtWbFcxcXFddpfTEys -Hn7sMd18+500AAAAAABoRgi6ANTos48+1F+ffzagSdz/W6vWyRozdqx69u6tHj17q3WbZEVHx8jh -cMhms8lsschkMslkMp10Mnufzyefzye3y6WqqiqVlBQrKzNTh9MOav/evdqwdq3SDh6o17nGxMTq -noce0m/ue4ALDwAAAADNDEEXgBrl5uToz48/qkXz5wVlf23btVPb9imKj49Xq9bJPwm9rDbbzwZd -fr9fZaWlKist0fHjx5WXk6OC/HylHzum3JzsoBxXq9bJ+s399+vWO+/iogMAAABAM0TQBaBW9uza -qT/+9kHtS01tkP1bLBbZw8NlsVhOGnRVVVbK5/M1yOv36tNXv/3DH3XuBRdysQEAAACgmSLoAlBr -q5Yv01+ff7bBwq6mEBkVpXMvuFB3/OZu9enXn4sMAAAAAM0YQReAgGxcv05vvfZ3rVm1stmfS68+ -fXXtjTfq0ivGKT4hgYsLAAAAAM0cQReAgGUcO6YvPvtE0yZNVFFhYbM7/i5du+mc8y/QZePGqVef -vlxQAAAAADAIgi4AdeJ0OrVs8SLN/W6mli5epMqKipA/5v4DB2rk6DE694KL1KtPH4WFhXEhAQAA -AMBACLoA1EtRYaHWr1mt5UuWaPHC+SosKAip42vVOlkjR4/WwMGnadiIM5TSsaNsNhsXDgAAAAAM -iKALQFCUlBTr8ME07di+VSuWLtW2LZtVXFTU6MdhsVjUqnVr9R84SKPPHquevfsopWNHxcbGymQy -caEAAAAAwMAIugAEldfjUUFBgfJyc7QvNVV7U/focFqajh09ouNZWUEf4hgXH6/2KR3UvkMHderc -WQMGDVZKh46Ki49Xi8REwi0AAAAAOIUQdAFoMF6vVy6XS263S8WFRcrLy1VBfr4K8vOVlZmhgvx8 -VZSXKzc3V1WVlbLarD+7H7/Pp9i4eCUmJckREaHkNm3UunWy4hMSFJ/QQkktWyoqKkphdrvsdjsV -DwAAAACnKIIuAAAAAAAAGIKZKgAAAAAAAIAREHQBAAAAAADAEAi6AAAAAAAAYAgEXQAAAAAAADAE -gi4AAAAAAAAYAkEXAAAAAAAADIGgCwAAAAAAAIZA0AUAAAAAAABDIOgCAAAAAACAIRB0AQAAAAAA -wBAIugAAAAAAAGAIBF0AAAAAAAAwBIIuAAAAAAAAGAJBFwAAAAAAAAyBoAsAAAAAAACGQNAFAAAA -AAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCARdAAAAAAAAMASCLgAAAAAAABgCQRcA -AAAAAAAMgaALAAAAAAAAhkDQBQAAAAAAAEMg6AIAAAAAAIAhEHQBAAAAAADAEAi6AAAAAAAAYAgE -XQAAAAAAADAEgi4AAAAAAAAYAkEXAAAAAAAADIGgCwAAAAAAAIZA0AUAAAAAAABDIOgCAAAAAACA -IRB0AQAAAAAAwBAIugAAAAAAAGAIBF0AAAAAAAAwBIIuAAAAAAAAGAJBFwAAAAAAAAyBoAsAAAAA -AACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCARdAAAAAAAAMASCLgAA -AAAAABgCQRcAAAAAAAAMgaALAAAAAAAAhkDQBQAAAAAAAEMg6AIAAAAAAIAhEHQBAAAAAADAEAi6 -AAAAAAAAYAgEXQAAAAAAADAEgi4AAAAAAAAYAkEXAAAAAAAADIGgCwAAAAAAAIZA0AUAAAAAAABD -IOgCAAAAAACAIRB0AQAAAAAAwBAIugAAAAAAAGAIBF0AAAAAAAAwBIIuAAAAAAAAGAJBFwAAAAAA -AAyBoAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCARdAAAA -AAAAMASCLgAAAAAAABgCQRcAAAAAAAAMgaALAAAAAAAAhkDQBQAAAAAAAEMg6AIAAAAAAIAhEHQB -AAAAAADAEAi6AAAAAAAAYAgEXQAAAAAAADAEgi4AAAAAAAAYAkEXAAAAAAAADIGgCwAAAAAAAIZA -0AUAAAAAAABDIOgCAAAAAACAIRB0AQAAAAAAwBAIugAAAAAAAGAIBF0AAAAAAAAwBIIuAAAAAAAA -GAJBFwAAAAAAAAyBoAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAA -AABgCARdAAAAAAAAMASCLgAAAAAAABgCQRcAAAAAAAAMgaALAAAAAAAAhkDQBQAAAAAAAEMg6AIA -AAAAAIAhEHQBAAAAAADAEAi6AAAAAAAAYAgEXQAAAAAAADAEgi4AAAAAAAAYAkEXAAAAAAAADIGg -CwAAAAAAAIZA0AUAAAAAAABDIOgCAAAAAACAIRB0AQAAAAAAwBAIugAAAAAAAGAIBF0AAAAAAAAw -BIIuAAAAAAAAGAJBFwAAAAAAAAyBoAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAA -AMAQCLoAAAAAAABgCARdAAAAAAAAMASCLgAAAAAAABgCQRcAAAAAAAAMgaALAAAAAAAAhkDQBQAA -AAAAAEMg6AIAAAAAAIAhEHQBAAAAAADAEAi6AAAAAAAAYAgEXQAAAAAAADAEgi4AAAAAAAAYAkEX -AAAAAAAADIGgCwAAAAAAAIZA0AUAAAAAAABDIOgCAAAAAACAIRB0AQAAAAAAwBAIugAAAAAAAGAI -BF0AAAAAAAAwBIIuAAAAAAAAGAJBFwAAAAAAAAyBoAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAA -gCEQdAEAAAAAAMAQCLoAAAAAAABgCARdAAAAAAAAMASCLgAAAAAAABgCQRcAAAAAAAAMgaALAAAA -AAAAhkDQBQAAAAAAAEMg6AIAAAAAAIAhEHQBAAAAAADAEAi6AAAAAAAAYAgEXQAAAAAAADAEgi4A -AAAAAAAYAkEXAAAAAAAADIGgCwAAAAAAAIZA0AUAAAAAAABDIOgCAAAAAACAIRB0AQAAAAAAwBAI -ugAAAAAAAGAIBF0AAAAAAAAwBIIuAAAAAAAAGAJBFwAAAAAAAAyBoAsAAAAAAACGQNAFAAAAAAAA -QyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCARdAAAAAAAAMASCLgAAAAAAABgCQRcAAAAA -AAAMgaALAAAAAAAAhkDQBQAAAAAAAEMg6AIAAAAAAIAhEHQBAAAAAADAEAi6AAAAAAAAYAgEXQAA -AAAAADAEgi4AAAAAAAAYAkEXAAAAAAAADIGgCwAAAAAAAIZA0AUAAAAAAABDIOgCAAAAAACAIRB0 -AQAAAAAAwBAIugAAAAAAAGAIBF0AAAAAAAAwBIIuAAAAAAAAGAJBFwAAAAAAAAyBoAsAAAAAAACG -QNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCARdAAAAAAAAMASCLgAAAAAA -ABgCQRcAAAAAAAAMgaALAAAAAAAAhkDQBQAAAAAAAEMg6AIAAAAAAIAhEHQBAAAAAADAEAi6AAAA -AAAAYAgEXQAAAAAAADAEgi4AAAAAAAAYAkEXAAAAAAAADIGgCwAAAAAAAIZA0AUAAAAAAABDIOgC -AAAAAACAIRB0AQAAAAAAwBAIugAAAAAAAGAIBF0AAAAAAAAwBIIuAAAAAAAAGAJBFwAAAAAAAAyB -oAsAAAAAAACGQNAFAAAAAAAAQyDoAgAAAAAAgCEQdAEAAAAAAMAQCLoAAAAAAABgCP83AL6WQ1Y7 -46xVAAAAAElFTkSuQmCC diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/img/logo.png b/uncloud_django_based/uncloud/static/uncloud_pay/img/logo.png deleted file mode 100644 index b82326f5a4af6bf6f9ab15a7f6ba0c035f6a2a5c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28401 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 diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/img/msg.png b/uncloud_django_based/uncloud/static/uncloud_pay/img/msg.png deleted file mode 100644 index 3b7b0c70b77b0b5ebde9f9be8d18b80c41223cb2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4654 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 diff --git a/uncloud_django_based/uncloud/static/uncloud_pay/img/twitter.png b/uncloud_django_based/uncloud/static/uncloud_pay/img/twitter.png deleted file mode 100644 index 4db6da08d64c20d74b5d17052126bcaf6bc12b23..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4821 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 diff --git a/uncloud_django_based/uncloud/uncloud_pay/templates/bill.html b/uncloud_django_based/uncloud/uncloud_pay/templates/bill.html index ab29158..8f6c217 100644 --- a/uncloud_django_based/uncloud/uncloud_pay/templates/bill.html +++ b/uncloud_django_based/uncloud/uncloud_pay/templates/bill.html @@ -1,815 +1,1128 @@ -{% load static %} - - - - - - - Bill name - - - - - - - - - -
- -
-
- ungleich glarus ag -
Bahnhofstrasse 1 -
8783 Linthal -
Switzerland -
-
-
- Faeh+Faeh GmbH -
Pascal Faeh - <pascal@faehundfaeh.ch> -
Via Nova -
7017 Flims -
-
-
-
- Rechnungsdatum: -
Rechnungsnummer -
Zahlbar bis - -
-
- 2018-04-21
- 20180421FAEH1
- 2018-05-20 -
-
-
-
-

RECHNUNG

-
-
-

- Beschreibung - Netto CHF -

-
-
-

- NAS Synology DS1817+ - 1234.56 -

-

- 10Gbit/s card Synology E10G17-F2 - 345.67 -

- - -

- 1OGbit/s switch HP - 567.89 -

-

- Festplatten 10 TB NAS RED Pro - 3456.78 -

-

- 10Gbit/s Transceiver Synology - 123.45 -

-

- 10Gbit/s Transceiver Switch kompatibel - 123.45 -

-

- NAS Synology DS1817+ - 1234.56 -

-

- 10Gbit/s card Synology E10G17-F2 - 345.67 -

- - -

- 1OGbit/s switch HP - 567.89 -

-

- Festplatten 10 TB NAS RED Pro - 3456.78 -

-

- 10Gbit/s Transceiver Synology - 123.45 -

-

- 10Gbit/s Transceiver Switch kompatibel - 123.45 -

-

- 10Gbit/s Transceiver Switch kompatibel - 123.45 -

-

- 10Gbit/s Transceiver Switch kompatibel - 123.45 -

-

- 10Gbit/s Transceiver Switch kompatibel - 123.45 -

-

- 10Gbit/s Transceiver Switch kompatibel - 123.45 -

-

- 10Gbit/s Transceiver Switch kompatibel - 123.45 -

-

- 10Gbit/s Transceiver Switch kompatibel - 123.45 -

-

- 10Gbit/s Transceiver Switch kompatibel - 123.45 -

- -
-
-

- Total - 12345.67 -

-

- 7.70% Mehrwertsteuer - 891.00 -

-
-
-

- Gesamtbetrag - 23456.78 -

-
- - - - +{% load static %} + + + + + + + + + Bill name + + + + + + +
+ +
+
+ ungleich glarus ag +
Bahnhofstrasse 1 +
8783 Linthal +
Switzerland +
+
+
+ Faeh+Faeh GmbH +
Pascal Faeh + <pascal@faehundfaeh.ch> +
Via Nova +
7017 Flims +
+
+
+
+ Rechnungsdatum: +
Rechnungsnummer +
Zahlbar bis + +
+
+ 2018-04-21
+ 20180421FAEH1
+ 2018-05-20 +
+
+
+
+

RECHNUNG

+
+
+

+ Beschreibung + Netto CHF +

+
+
+

+ NAS Synology DS1817+ + 1234.56 +

+

+ 10Gbit/s card Synology E10G17-F2 + 345.67 +

+ + +

+ 1OGbit/s switch HP + 567.89 +

+

+ Festplatten 10 TB NAS RED Pro + 3456.78 +

+

+ 10Gbit/s Transceiver Synology + 123.45 +

+

+ 10Gbit/s Transceiver Switch kompatibel + 123.45 +

+

+ NAS Synology DS1817+ + 1234.56 +

+

+ 10Gbit/s card Synology E10G17-F2 + 345.67 +

+ + +

+ 1OGbit/s switch HP + 567.89 +

+

+ Festplatten 10 TB NAS RED Pro + 3456.78 +

+

+ 10Gbit/s Transceiver Synology + 123.45 +

+

+ 10Gbit/s Transceiver Switch kompatibel + 123.45 +

+

+ 10Gbit/s Transceiver Switch kompatibel + 123.45 +

+

+ 10Gbit/s Transceiver Switch kompatibel + 123.45 +

+

+ 10Gbit/s Transceiver Switch kompatibel + 123.45 +

+

+ 10Gbit/s Transceiver Switch kompatibel + 123.45 +

+

+ 10Gbit/s Transceiver Switch kompatibel + 123.45 +

+

+ 10Gbit/s Transceiver Switch kompatibel + 123.45 +

+

+ 10Gbit/s Transceiver Switch kompatibel + 123.45 +

+ +
+
+

+ Total + 12345.67 +

+

+ 7.70% Mehrwertsteuer + 891.00 +

+
+
+

+ Gesamtbetrag + 23456.78 +

+
+ + + + From 5d084a5716d83b0153e5aa8df6015bf25aacccab Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Fri, 3 Apr 2020 19:27:49 +0200 Subject: [PATCH 267/284] phase in vpn Signed-off-by: Nico Schottelius --- .../uncloud/uncloud/settings.py | 1 + uncloud_django_based/uncloud/uncloud/urls.py | 21 ++-- .../uncloud_net/migrations/0001_initial.py | 63 +++++++++++ .../uncloud_net/migrations/__init__.py | 0 .../uncloud/uncloud_net/models.py | 34 +++++- .../uncloud/uncloud_net/serializers.py | 13 +++ .../uncloud/uncloud_net/views.py | 18 +++- .../uncloud_pay/templates/bill.html.template | 101 ------------------ .../migrations/0008_auto_20200403_1727.py | 33 ++++++ .../uncloud/uncloud_vm/models.py | 4 - .../migrations/0004_auto_20200403_1727.py | 18 ++++ 11 files changed, 190 insertions(+), 116 deletions(-) create mode 100644 uncloud_django_based/uncloud/uncloud_net/migrations/0001_initial.py create mode 100644 uncloud_django_based/uncloud/uncloud_net/migrations/__init__.py create mode 100644 uncloud_django_based/uncloud/uncloud_net/serializers.py delete mode 100644 uncloud_django_based/uncloud/uncloud_pay/templates/bill.html.template create mode 100644 uncloud_django_based/uncloud/uncloud_vm/migrations/0008_auto_20200403_1727.py create mode 100644 uncloud_django_based/uncloud/ungleich_service/migrations/0004_auto_20200403_1727.py diff --git a/uncloud_django_based/uncloud/uncloud/settings.py b/uncloud_django_based/uncloud/uncloud/settings.py index 94476d1..871ac8e 100644 --- a/uncloud_django_based/uncloud/uncloud/settings.py +++ b/uncloud_django_based/uncloud/uncloud/settings.py @@ -62,6 +62,7 @@ INSTALLED_APPS = [ 'uncloud', 'uncloud_pay', 'uncloud_auth', + 'uncloud_net', 'uncloud_storage', 'uncloud_vm', 'ungleich_service', diff --git a/uncloud_django_based/uncloud/uncloud/urls.py b/uncloud_django_based/uncloud/uncloud/urls.py index 8de3fa5..d7550db 100644 --- a/uncloud_django_based/uncloud/uncloud/urls.py +++ b/uncloud_django_based/uncloud/uncloud/urls.py @@ -22,11 +22,12 @@ from django.conf.urls.static import static from rest_framework import routers -from uncloud_vm import views as vmviews -from uncloud_pay import views as payviews -from ungleich_service import views as serviceviews -from opennebula import views as oneviews +from opennebula import views as oneviews from uncloud_auth import views as authviews +from uncloud_net import views as netviews +from uncloud_pay import views as payviews +from uncloud_vm import views as vmviews +from ungleich_service import views as serviceviews router = routers.DefaultRouter() @@ -44,6 +45,10 @@ router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') # Services router.register(r'service/matrix', serviceviews.MatrixServiceProductViewSet, basename='matrixserviceproduct') +# Net +router.register(r'net/vpn', netviews.VPNProductViewSet, basename='vpnproduct') + + # Pay router.register(r'payment-method', payviews.PaymentMethodViewSet, basename='payment-method') router.register(r'bill', payviews.BillViewSet, basename='bill') @@ -64,12 +69,10 @@ router.register(r'admin/opennebula', oneviews.VMViewSet, basename='opennebula') router.register(r'user', authviews.UserViewSet, basename='user') -# Testing -# router.register(r'user', authviews.UserViewSet, basename='user') -from uncloud_net import views as netview - urlpatterns = [ path('', include(router.urls)), - path('pdf/', payviews.MyPDFView.as_view(), name='pdf'), + # web/ = stuff to view in the browser + + path('web/pdf/', payviews.MyPDFView.as_view(), name='pdf'), path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) # for login to REST API ] diff --git a/uncloud_django_based/uncloud/uncloud_net/migrations/0001_initial.py b/uncloud_django_based/uncloud/uncloud_net/migrations/0001_initial.py new file mode 100644 index 0000000..b40e0b3 --- /dev/null +++ b/uncloud_django_based/uncloud/uncloud_net/migrations/0001_initial.py @@ -0,0 +1,63 @@ +# Generated by Django 3.0.5 on 2020-04-03 17:27 + +from django.conf import settings +import django.contrib.postgres.fields.jsonb +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_pay', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='MACAdress', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + ), + migrations.CreateModel( + name='VPNPool', + fields=[ + ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), + ('network', models.GenericIPAddressField(editable=False, primary_key=True, serialize=False)), + ('network_size', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='VPNProduct', + fields=[ + ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32)), + ('network', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.VPNPool')), + ('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='VPNNetwork', + fields=[ + ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), + ('network', models.GenericIPAddressField(editable=False, primary_key=True, serialize=False)), + ('vpnpool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.VPNPool')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/uncloud_django_based/uncloud/uncloud_net/migrations/__init__.py b/uncloud_django_based/uncloud/uncloud_net/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uncloud_django_based/uncloud/uncloud_net/models.py b/uncloud_django_based/uncloud/uncloud_net/models.py index 6d0c742..934eeb5 100644 --- a/uncloud_django_based/uncloud/uncloud_net/models.py +++ b/uncloud_django_based/uncloud/uncloud_net/models.py @@ -1,4 +1,36 @@ from django.db import models +from django.contrib.auth import get_user_model +from django.core.validators import MinValueValidator, MaxValueValidator + + +from uncloud_pay.models import Product, RecurringPeriod +from uncloud.models import UncloudModel, UncloudStatus + +class VPNPool(UncloudModel): + """ + Network address pools from which VPNs can be created + """ + + network = models.GenericIPAddressField(primary_key=True, + editable=False) + + network_size = models.IntegerField(validators=[MinValueValidator(0), + MaxValueValidator(128)]) + +class VPNNetwork(UncloudModel): + """ + A selected network. Used for tracking reservations / used networks + """ + vpnpool = models.ForeignKey(VPNPool, + on_delete=models.CASCADE) + + network = models.GenericIPAddressField(primary_key=True, + editable=False) + +class VPNProduct(Product): + network = models.ForeignKey(VPNPool, + on_delete=models.CASCADE) + class MACAdress(models.Model): - prefix = 0x420000000000 + default_prefix = 0x420000000000 diff --git a/uncloud_django_based/uncloud/uncloud_net/serializers.py b/uncloud_django_based/uncloud/uncloud_net/serializers.py new file mode 100644 index 0000000..856688b --- /dev/null +++ b/uncloud_django_based/uncloud/uncloud_net/serializers.py @@ -0,0 +1,13 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from .models import * + +class VPNProductSerializer(serializers.ModelSerializer): + + network_size = serializers.IntegerField(min_value=0, + max_value=128) + + class Meta: + model = VPNProduct + fields = '__all__' diff --git a/uncloud_django_based/uncloud/uncloud_net/views.py b/uncloud_django_based/uncloud/uncloud_net/views.py index 91ea44a..f22da2f 100644 --- a/uncloud_django_based/uncloud/uncloud_net/views.py +++ b/uncloud_django_based/uncloud/uncloud_net/views.py @@ -1,3 +1,19 @@ from django.shortcuts import render +from rest_framework import viewsets, permissions -# Create your views here. + +from .models import * +from .serializers import * + + +class VPNProductViewSet(viewsets.ModelViewSet): + serializer_class = VPNProductSerializer + permission_classes = [permissions.IsAdminUser] + + def get_queryset(self): + if self.request.user.is_superuser: + obj = VPNProduct.objects.all() + else: + obj = VPNProduct.objects.filter(owner=self.request.user) + + return obj diff --git a/uncloud_django_based/uncloud/uncloud_pay/templates/bill.html.template b/uncloud_django_based/uncloud/uncloud_pay/templates/bill.html.template deleted file mode 100644 index 019ee81..0000000 --- a/uncloud_django_based/uncloud/uncloud_pay/templates/bill.html.template +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - ungleich - - - - - - -
- -
-
- ungleich glarus ag -
Bahnhofstrasse 1 -
8783 Linthal -
Switzerland -
-
-
- $company_name -
$user_name - $user_email -
$user_street -
$user_postal $user_city -
$user_country -
-
-
- Rechnungsdatum: -
Rechnungsnummer -
Zahlbar bis - -
-
- $invoice_date
- $invoice_number
- $invoice_payable_on -
-
-
-
-

RECHNUNG

-
-
-

- Beschreibung - Netto CHF -

-
-
- $product_names_and_amounts -
-
-

- Total - $total_amount -

-

- 7.70% Mehrwertsteuer - $total_vat_amount -

-
-
-

- Gesamtbetrag - $grand_total -

-
- - - - \ No newline at end of file diff --git a/uncloud_django_based/uncloud/uncloud_vm/migrations/0008_auto_20200403_1727.py b/uncloud_django_based/uncloud/uncloud_vm/migrations/0008_auto_20200403_1727.py new file mode 100644 index 0000000..5f4b494 --- /dev/null +++ b/uncloud_django_based/uncloud/uncloud_vm/migrations/0008_auto_20200403_1727.py @@ -0,0 +1,33 @@ +# Generated by Django 3.0.5 on 2020-04-03 17:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_vm', '0007_vmhost_vmcluster'), + ] + + operations = [ + migrations.AlterField( + model_name='vmdiskimageproduct', + name='status', + field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32), + ), + migrations.AlterField( + model_name='vmhost', + name='status', + field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32), + ), + migrations.AlterField( + model_name='vmproduct', + name='status', + field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32), + ), + migrations.AlterField( + model_name='vmsnapshotproduct', + name='status', + field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32), + ), + ] diff --git a/uncloud_django_based/uncloud/uncloud_vm/models.py b/uncloud_django_based/uncloud/uncloud_vm/models.py index 3b2c46b..8644e93 100644 --- a/uncloud_django_based/uncloud/uncloud_vm/models.py +++ b/uncloud_django_based/uncloud/uncloud_vm/models.py @@ -3,10 +3,6 @@ import uuid from django.db import models from django.contrib.auth import get_user_model - -# Uncomment if you override model's clean method -# from django.core.exceptions import ValidationError - from uncloud_pay.models import Product, RecurringPeriod from uncloud.models import UncloudModel, UncloudStatus diff --git a/uncloud_django_based/uncloud/ungleich_service/migrations/0004_auto_20200403_1727.py b/uncloud_django_based/uncloud/ungleich_service/migrations/0004_auto_20200403_1727.py new file mode 100644 index 0000000..eed8d33 --- /dev/null +++ b/uncloud_django_based/uncloud/ungleich_service/migrations/0004_auto_20200403_1727.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.5 on 2020-04-03 17:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ungleich_service', '0003_auto_20200322_1758'), + ] + + operations = [ + migrations.AlterField( + model_name='matrixserviceproduct', + name='status', + field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32), + ), + ] From d537e9e2f0868cad7bdf69888815305aca29f329 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Mon, 6 Apr 2020 22:06:34 +0200 Subject: [PATCH 268/284] [doc] add new readme's --- ...README-how-to-configure-remote-uncloud-clients.org | 5 +++++ uncloud_django_based/uncloud/README-vpn.org | 11 +++++++++++ 2 files changed, 16 insertions(+) create mode 100644 uncloud_django_based/uncloud/README-how-to-configure-remote-uncloud-clients.org create mode 100644 uncloud_django_based/uncloud/README-vpn.org diff --git a/uncloud_django_based/uncloud/README-how-to-configure-remote-uncloud-clients.org b/uncloud_django_based/uncloud/README-how-to-configure-remote-uncloud-clients.org new file mode 100644 index 0000000..4b2b361 --- /dev/null +++ b/uncloud_django_based/uncloud/README-how-to-configure-remote-uncloud-clients.org @@ -0,0 +1,5 @@ +* What is a remote uncloud client? +** Systems that configure themselves for the use with uncloud +** Examples are VMHosts, VPN Servers, etc. +* Which access do these clients need? +** They need read / write access to the database diff --git a/uncloud_django_based/uncloud/README-vpn.org b/uncloud_django_based/uncloud/README-vpn.org new file mode 100644 index 0000000..8f1f368 --- /dev/null +++ b/uncloud_django_based/uncloud/README-vpn.org @@ -0,0 +1,11 @@ +* How to add a new VPN Host +** Install wireguard to the host +** Install uncloud to the host +** Add `python manage.py vpn --hostname fqdn-of-this-host` to the crontab +** Use the CLI to configure one or more VPN Networks for this host +* Example of adding a VPN host at ungleich +** Create a new dual stack alpine VM +** Add it to DNS as vpn-XXX.ungleich.ch +** Route a /40 network to its IPv6 address +** Install wireguard on it +** TODO Enable wireguard on boot From 198aaea48a060acd734926d54940e47f6fef41fd Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Mon, 6 Apr 2020 22:06:48 +0200 Subject: [PATCH 269/284] Remove unused ldaptest --- uncloud_django_based/meow-payv1/ldaptest.py | 27 --------------------- 1 file changed, 27 deletions(-) delete mode 100644 uncloud_django_based/meow-payv1/ldaptest.py diff --git a/uncloud_django_based/meow-payv1/ldaptest.py b/uncloud_django_based/meow-payv1/ldaptest.py deleted file mode 100644 index eb5a5be..0000000 --- a/uncloud_django_based/meow-payv1/ldaptest.py +++ /dev/null @@ -1,27 +0,0 @@ -import ldap3 -from ldap3 import Server, Connection, ObjectDef, Reader, ALL -import os -import sys - -def is_valid_ldap_user(username, password): - server = Server("ldaps://ldap1.ungleich.ch") - is_valid = False - - try: - conn = Connection(server, 'cn={},ou=users,dc=ungleich,dc=ch'.format(username), password, auto_bind=True) - is_valid = True - except Exception as e: - print("user: {}".format(e)) - - try: - conn = Connection(server, 'uid={},ou=customer,dc=ungleich,dc=ch'.format(username), password, auto_bind=True) - is_valid = True - except Exception as e: - print("customer: {}".format(e)) - - - return is_valid - - -if __name__ == '__main__': - print(is_valid_ldap_user(sys.argv[1], sys.argv[2])) From 06c4a5643cc54bb0833b9f4624ecad8fdd304245 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Mon, 6 Apr 2020 22:08:29 +0200 Subject: [PATCH 270/284] [doc] move readme to subdir --- .../{ => doc}/README-how-to-configure-remote-uncloud-clients.org | 0 .../uncloud/{ => doc}/README-how-to-create-a-product.md | 0 uncloud_django_based/uncloud/{ => doc}/README-object-relations.md | 0 uncloud_django_based/uncloud/{ => doc}/README-vpn.org | 0 uncloud_django_based/uncloud/{ => doc}/README.md | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename uncloud_django_based/uncloud/{ => doc}/README-how-to-configure-remote-uncloud-clients.org (100%) rename uncloud_django_based/uncloud/{ => doc}/README-how-to-create-a-product.md (100%) rename uncloud_django_based/uncloud/{ => doc}/README-object-relations.md (100%) rename uncloud_django_based/uncloud/{ => doc}/README-vpn.org (100%) rename uncloud_django_based/uncloud/{ => doc}/README.md (100%) diff --git a/uncloud_django_based/uncloud/README-how-to-configure-remote-uncloud-clients.org b/uncloud_django_based/uncloud/doc/README-how-to-configure-remote-uncloud-clients.org similarity index 100% rename from uncloud_django_based/uncloud/README-how-to-configure-remote-uncloud-clients.org rename to uncloud_django_based/uncloud/doc/README-how-to-configure-remote-uncloud-clients.org diff --git a/uncloud_django_based/uncloud/README-how-to-create-a-product.md b/uncloud_django_based/uncloud/doc/README-how-to-create-a-product.md similarity index 100% rename from uncloud_django_based/uncloud/README-how-to-create-a-product.md rename to uncloud_django_based/uncloud/doc/README-how-to-create-a-product.md diff --git a/uncloud_django_based/uncloud/README-object-relations.md b/uncloud_django_based/uncloud/doc/README-object-relations.md similarity index 100% rename from uncloud_django_based/uncloud/README-object-relations.md rename to uncloud_django_based/uncloud/doc/README-object-relations.md diff --git a/uncloud_django_based/uncloud/README-vpn.org b/uncloud_django_based/uncloud/doc/README-vpn.org similarity index 100% rename from uncloud_django_based/uncloud/README-vpn.org rename to uncloud_django_based/uncloud/doc/README-vpn.org diff --git a/uncloud_django_based/uncloud/README.md b/uncloud_django_based/uncloud/doc/README.md similarity index 100% rename from uncloud_django_based/uncloud/README.md rename to uncloud_django_based/uncloud/doc/README.md From 096f7e05c0f0a238bc71f37fd2ac48030acb93c4 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Mon, 6 Apr 2020 22:29:41 +0200 Subject: [PATCH 271/284] [migration] new models for uncloud_net --- .../migrations/0002_auto_20200406_2021.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 uncloud_django_based/uncloud/uncloud_net/migrations/0002_auto_20200406_2021.py diff --git a/uncloud_django_based/uncloud/uncloud_net/migrations/0002_auto_20200406_2021.py b/uncloud_django_based/uncloud/uncloud_net/migrations/0002_auto_20200406_2021.py new file mode 100644 index 0000000..82e4c7d --- /dev/null +++ b/uncloud_django_based/uncloud/uncloud_net/migrations/0002_auto_20200406_2021.py @@ -0,0 +1,70 @@ +# Generated by Django 3.0.5 on 2020-04-06 20:21 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('uncloud_net', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='vpnnetwork', + name='order', + field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'), + ), + migrations.AddField( + model_name='vpnnetwork', + name='owner', + field=models.ForeignKey(default=0, editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + migrations.AddField( + model_name='vpnnetwork', + name='status', + field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32), + ), + migrations.AlterField( + model_name='vpnnetwork', + name='network', + field=models.GenericIPAddressField(editable=False, unique=True), + ), + migrations.AddField( + model_name='vpnnetwork', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), + ), + migrations.AddField( + model_name='vpnnetwork', + name='wireguard_public_key', + field=models.CharField(default='', max_length=48), + preserve_default=False, + ), + migrations.AddField( + model_name='vpnpool', + name='vpn_hostname', + field=models.CharField(default='', max_length=256), + preserve_default=False, + ), + migrations.AddField( + model_name='vpnpool', + name='wireguard_private_key', + field=models.CharField(default='', max_length=48), + preserve_default=False, + ), + migrations.AlterField( + model_name='vpnpool', + name='network', + field=models.GenericIPAddressField(primary_key=True, serialize=False), + ), + migrations.DeleteModel( + name='VPNProduct', + ), + ] From 913e992a4809f72806093a96638782c04a5e157f Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Mon, 6 Apr 2020 22:30:01 +0200 Subject: [PATCH 272/284] [vpn] fix urls --- uncloud_django_based/uncloud/uncloud/urls.py | 4 ++- .../uncloud/uncloud_net/models.py | 26 ++++++++++--------- .../uncloud/uncloud_net/serializers.py | 9 +++++-- .../uncloud/uncloud_net/views.py | 14 +++++++--- 4 files changed, 34 insertions(+), 19 deletions(-) diff --git a/uncloud_django_based/uncloud/uncloud/urls.py b/uncloud_django_based/uncloud/uncloud/urls.py index d7550db..54f4d36 100644 --- a/uncloud_django_based/uncloud/uncloud/urls.py +++ b/uncloud_django_based/uncloud/uncloud/urls.py @@ -46,7 +46,7 @@ router.register(r'vm/vm', vmviews.VMProductViewSet, basename='vmproduct') router.register(r'service/matrix', serviceviews.MatrixServiceProductViewSet, basename='matrixserviceproduct') # Net -router.register(r'net/vpn', netviews.VPNProductViewSet, basename='vpnproduct') +router.register(r'net/vpn', netviews.VPNNetworkViewSet, basename='vpnnet') # Pay @@ -63,6 +63,8 @@ router.register(r'admin/payment', payviews.AdminPaymentViewSet, basename='admin/ router.register(r'admin/order', payviews.AdminOrderViewSet, basename='admin/order') router.register(r'admin/vmhost', vmviews.VMHostViewSet) router.register(r'admin/vmcluster', vmviews.VMClusterViewSet) +router.register(r'admin/vpnpool', netviews.VPNPoolViewSet) + router.register(r'admin/opennebula', oneviews.VMViewSet, basename='opennebula') # User/Account diff --git a/uncloud_django_based/uncloud/uncloud_net/models.py b/uncloud_django_based/uncloud/uncloud_net/models.py index 934eeb5..d811902 100644 --- a/uncloud_django_based/uncloud/uncloud_net/models.py +++ b/uncloud_django_based/uncloud/uncloud_net/models.py @@ -6,31 +6,33 @@ from django.core.validators import MinValueValidator, MaxValueValidator from uncloud_pay.models import Product, RecurringPeriod from uncloud.models import UncloudModel, UncloudStatus + +class MACAdress(models.Model): + default_prefix = 0x420000000000 + class VPNPool(UncloudModel): """ Network address pools from which VPNs can be created """ - network = models.GenericIPAddressField(primary_key=True, - editable=False) + network = models.GenericIPAddressField(primary_key=True) network_size = models.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(128)]) -class VPNNetwork(UncloudModel): + vpn_hostname = models.CharField(max_length=256) + + wireguard_private_key = models.CharField(max_length=48) + + +class VPNNetwork(Product): """ A selected network. Used for tracking reservations / used networks """ vpnpool = models.ForeignKey(VPNPool, on_delete=models.CASCADE) - network = models.GenericIPAddressField(primary_key=True, - editable=False) + network = models.GenericIPAddressField(editable=False, + unique=True) -class VPNProduct(Product): - network = models.ForeignKey(VPNPool, - on_delete=models.CASCADE) - - -class MACAdress(models.Model): - default_prefix = 0x420000000000 + wireguard_public_key = models.CharField(max_length=48) diff --git a/uncloud_django_based/uncloud/uncloud_net/serializers.py b/uncloud_django_based/uncloud/uncloud_net/serializers.py index 856688b..7f3ab8e 100644 --- a/uncloud_django_based/uncloud/uncloud_net/serializers.py +++ b/uncloud_django_based/uncloud/uncloud_net/serializers.py @@ -3,11 +3,16 @@ from rest_framework import serializers from .models import * -class VPNProductSerializer(serializers.ModelSerializer): +class VPNPoolSerializer(serializers.ModelSerializer): + class Meta: + model = VPNPool + fields = '__all__' + +class VPNNetworkSerializer(serializers.ModelSerializer): network_size = serializers.IntegerField(min_value=0, max_value=128) class Meta: - model = VPNProduct + model = VPNNetwork fields = '__all__' diff --git a/uncloud_django_based/uncloud/uncloud_net/views.py b/uncloud_django_based/uncloud/uncloud_net/views.py index f22da2f..7afc99d 100644 --- a/uncloud_django_based/uncloud/uncloud_net/views.py +++ b/uncloud_django_based/uncloud/uncloud_net/views.py @@ -6,14 +6,20 @@ from .models import * from .serializers import * -class VPNProductViewSet(viewsets.ModelViewSet): - serializer_class = VPNProductSerializer +class VPNPoolViewSet(viewsets.ModelViewSet): + serializer_class = VPNPoolSerializer + permission_classes = [permissions.IsAdminUser] + queryset = VPNPool.objects.all() + + +class VPNNetworkViewSet(viewsets.ModelViewSet): + serializer_class = VPNNetworkSerializer permission_classes = [permissions.IsAdminUser] def get_queryset(self): if self.request.user.is_superuser: - obj = VPNProduct.objects.all() + obj = VPNNetwork.objects.all() else: - obj = VPNProduct.objects.filter(owner=self.request.user) + obj = VPNNetwork.objects.filter(owner=self.request.user) return obj From 938f0a3390206745d3d99f2ee754246a40bf2d5c Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Tue, 7 Apr 2020 19:45:16 +0200 Subject: [PATCH 273/284] update to work on different computer Signed-off-by: Nico Schottelius --- .../uncloud/doc/README-vpn.org | 14 ++++ .../uncloud_net/management/commands/vpn.py | 44 ++++++++++++ .../uncloud_net/migrations/0001_initial.py | 25 ++++--- .../migrations/0002_auto_20200406_2021.py | 70 ------------------- .../uncloud/uncloud_net/models.py | 24 +++++-- .../uncloud/uncloud_net/serializers.py | 48 ++++++++++++- .../uncloud/uncloud_net/views.py | 2 + 7 files changed, 139 insertions(+), 88 deletions(-) create mode 100644 uncloud_django_based/uncloud/uncloud_net/management/commands/vpn.py delete mode 100644 uncloud_django_based/uncloud/uncloud_net/migrations/0002_auto_20200406_2021.py diff --git a/uncloud_django_based/uncloud/doc/README-vpn.org b/uncloud_django_based/uncloud/doc/README-vpn.org index 8f1f368..e7255d8 100644 --- a/uncloud_django_based/uncloud/doc/README-vpn.org +++ b/uncloud_django_based/uncloud/doc/README-vpn.org @@ -9,3 +9,17 @@ ** Route a /40 network to its IPv6 address ** Install wireguard on it ** TODO Enable wireguard on boot +** TODO Create a new VPNPool on uncloud with +*** the network address (selecting from our existing pool) +*** the network size (/...) +*** the vpn host that provides the network (selecting the created VM) +*** the wireguard private key of the vpn host (using wg genkey) +*** http command +``` +http -a nicoschottelius:$(pass + ungleich.ch/nico.schottelius@ungleich.ch) + http://localhost:8000/admin/vpnpool/ network=2a0a:e5c1:200:: \ + network_size=40 subnetwork_size=48 + vpn_hostname=vpn-2a0ae5c1200.ungleich.ch + wireguard_private_key=... +``` diff --git a/uncloud_django_based/uncloud/uncloud_net/management/commands/vpn.py b/uncloud_django_based/uncloud/uncloud_net/management/commands/vpn.py new file mode 100644 index 0000000..c63e5a0 --- /dev/null +++ b/uncloud_django_based/uncloud/uncloud_net/management/commands/vpn.py @@ -0,0 +1,44 @@ +import sys +from datetime import datetime + +from django.core.management.base import BaseCommand + +from django.contrib.auth import get_user_model + +from opennebula.models import VM as VMModel +from uncloud_vm.models import VMHost, VMProduct, VMNetworkCard, VMDiskImageProduct, VMDiskProduct, VMCluster + +import logging +log = logging.getLogger(__name__) + + +wireguard_template=""" + +[Interface] +ListenPort = 51820 +PrivateKey = {privatekey} + +# Nico, 2019-01-23, Switzerland +#[Peer] +#PublicKey = kL1S/Ipq6NkFf1MAsNRou4b9VoUsnnb4ZxgiBrH0zA8= +#AllowedIPs = 2a0a:e5c1:101::/48 +""" + +class Command(BaseCommand): + help = 'General uncloud commands' + + def add_arguments(self, parser): + parser.add_argument('--hostname', action='store_true', help='Name of this VPN Host', + required=True) + + def handle(self, *args, **options): +# for net + if options['bootstrap']: + self.bootstrap() + + self.create_vpn_config(options['hostname']) + + def create_vpn_config(self, hostname): + for pool in VPNPool.objects.filter(vpn_hostname + default_cluster = VPNNetwork.objects.get_or_create(name="default") +# local_host = diff --git a/uncloud_django_based/uncloud/uncloud_net/migrations/0001_initial.py b/uncloud_django_based/uncloud/uncloud_net/migrations/0001_initial.py index b40e0b3..940d63f 100644 --- a/uncloud_django_based/uncloud/uncloud_net/migrations/0001_initial.py +++ b/uncloud_django_based/uncloud/uncloud_net/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.5 on 2020-04-03 17:27 +# Generated by Django 3.0.5 on 2020-04-06 21:38 from django.conf import settings import django.contrib.postgres.fields.jsonb @@ -28,22 +28,23 @@ class Migration(migrations.Migration): name='VPNPool', fields=[ ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), - ('network', models.GenericIPAddressField(editable=False, primary_key=True, serialize=False)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('network', models.GenericIPAddressField(unique=True)), ('network_size', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])), + ('subnetwork_size', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(128)])), + ('vpn_hostname', models.CharField(max_length=256)), + ('wireguard_private_key', models.CharField(max_length=48)), ], options={ 'abstract': False, }, ), migrations.CreateModel( - name='VPNProduct', + name='VPNNetworkReservation', fields=[ ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32)), - ('network', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.VPNPool')), - ('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), - ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('address', models.GenericIPAddressField(primary_key=True, serialize=False)), + ('vpnpool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.VPNPool')), ], options={ 'abstract': False, @@ -53,8 +54,12 @@ class Migration(migrations.Migration): name='VPNNetwork', fields=[ ('extra_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, editable=False, null=True)), - ('network', models.GenericIPAddressField(editable=False, primary_key=True, serialize=False)), - ('vpnpool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.VPNPool')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32)), + ('wireguard_public_key', models.CharField(max_length=48)), + ('network', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='uncloud_net.VPNNetworkReservation')), + ('order', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order')), + ('owner', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ 'abstract': False, diff --git a/uncloud_django_based/uncloud/uncloud_net/migrations/0002_auto_20200406_2021.py b/uncloud_django_based/uncloud/uncloud_net/migrations/0002_auto_20200406_2021.py deleted file mode 100644 index 82e4c7d..0000000 --- a/uncloud_django_based/uncloud/uncloud_net/migrations/0002_auto_20200406_2021.py +++ /dev/null @@ -1,70 +0,0 @@ -# Generated by Django 3.0.5 on 2020-04-06 20:21 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('uncloud_pay', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('uncloud_net', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='vpnnetwork', - name='order', - field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='uncloud_pay.Order'), - ), - migrations.AddField( - model_name='vpnnetwork', - name='owner', - field=models.ForeignKey(default=0, editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - preserve_default=False, - ), - migrations.AddField( - model_name='vpnnetwork', - name='status', - field=models.CharField(choices=[('PENDING', 'Pending'), ('AWAITING_PAYMENT', 'Awaiting payment'), ('BEING_CREATED', 'Being created'), ('SCHEDULED', 'Scheduled'), ('ACTIVE', 'Active'), ('MODIFYING', 'Modifying'), ('DELETED', 'Deleted'), ('DISABLED', 'Disabled'), ('UNUSABLE', 'Unusable')], default='PENDING', max_length=32), - ), - migrations.AlterField( - model_name='vpnnetwork', - name='network', - field=models.GenericIPAddressField(editable=False, unique=True), - ), - migrations.AddField( - model_name='vpnnetwork', - name='uuid', - field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False), - ), - migrations.AddField( - model_name='vpnnetwork', - name='wireguard_public_key', - field=models.CharField(default='', max_length=48), - preserve_default=False, - ), - migrations.AddField( - model_name='vpnpool', - name='vpn_hostname', - field=models.CharField(default='', max_length=256), - preserve_default=False, - ), - migrations.AddField( - model_name='vpnpool', - name='wireguard_private_key', - field=models.CharField(default='', max_length=48), - preserve_default=False, - ), - migrations.AlterField( - model_name='vpnpool', - name='network', - field=models.GenericIPAddressField(primary_key=True, serialize=False), - ), - migrations.DeleteModel( - name='VPNProduct', - ), - ] diff --git a/uncloud_django_based/uncloud/uncloud_net/models.py b/uncloud_django_based/uncloud/uncloud_net/models.py index d811902..a3939ee 100644 --- a/uncloud_django_based/uncloud/uncloud_net/models.py +++ b/uncloud_django_based/uncloud/uncloud_net/models.py @@ -1,3 +1,5 @@ +import uuid + from django.db import models from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator, MaxValueValidator @@ -15,24 +17,36 @@ class VPNPool(UncloudModel): Network address pools from which VPNs can be created """ - network = models.GenericIPAddressField(primary_key=True) + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + network = models.GenericIPAddressField(unique=True) network_size = models.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(128)]) + subnetwork_size = models.IntegerField(validators=[MinValueValidator(0), + MaxValueValidator(128)]) + vpn_hostname = models.CharField(max_length=256) wireguard_private_key = models.CharField(max_length=48) -class VPNNetwork(Product): +class VPNNetworkReservation(UncloudModel): """ - A selected network. Used for tracking reservations / used networks + This class tracks the used VPN networks. It will be deleted, when the product is cancelled. """ vpnpool = models.ForeignKey(VPNPool, on_delete=models.CASCADE) - network = models.GenericIPAddressField(editable=False, - unique=True) + address = models.GenericIPAddressField(primary_key=True) + + +class VPNNetwork(Product): + """ + A selected network. Used for tracking reservations / used networks + """ + network = models.ForeignKey(VPNNetworkReservation, + on_delete=models.CASCADE, + editable=False) wireguard_public_key = models.CharField(max_length=48) diff --git a/uncloud_django_based/uncloud/uncloud_net/serializers.py b/uncloud_django_based/uncloud/uncloud_net/serializers.py index 7f3ab8e..2c54b4f 100644 --- a/uncloud_django_based/uncloud/uncloud_net/serializers.py +++ b/uncloud_django_based/uncloud/uncloud_net/serializers.py @@ -1,4 +1,7 @@ +import base64 + from django.contrib.auth import get_user_model +from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from .models import * @@ -10,9 +13,48 @@ class VPNPoolSerializer(serializers.ModelSerializer): class VPNNetworkSerializer(serializers.ModelSerializer): - network_size = serializers.IntegerField(min_value=0, - max_value=128) - class Meta: model = VPNNetwork fields = '__all__' + + # This is required for finding the VPN pool, but does not + # exist in the model + network_size = serializers.IntegerField(min_value=0, + max_value=128) + + def validate_wireguard_public_key(self, value): + msg = _("Supplied key is not a valid wireguard public key") + + """ FIXME: verify that this does not create broken wireguard config files, + i.e. contains \n or similar! + We might even need to be more strict to not break wireguard... + """ + print(value) + + try: + base64.standard_b64decode(value) + except Exception as e: + raise serializers.ValidationError(msg) + + if '\n' in value: + raise serializers.ValidationError(msg) + + return value + + def validate(self, data): + + # FIXME: filter for status = active or similar + all_pools = VPNPool.objects.all() + sizes = [ p.subnetwork_size for p in all_pools ] + + pools = VPNPool.objects.filter(subnetwork_size=data['network_size']) + + if len(pools) == 0: + msg = _("No pool available for networks with size = {}. Available are: {}".format(data['network_size'], sizes)) + raise serializers.ValidationError(msg) + + + return data + + def create(self, validated_data): + from_pool = diff --git a/uncloud_django_based/uncloud/uncloud_net/views.py b/uncloud_django_based/uncloud/uncloud_net/views.py index 7afc99d..a3f5284 100644 --- a/uncloud_django_based/uncloud/uncloud_net/views.py +++ b/uncloud_django_based/uncloud/uncloud_net/views.py @@ -1,4 +1,6 @@ + from django.shortcuts import render + from rest_framework import viewsets, permissions From 8986835c7e58b2d43ad906468b9993caaae196ef Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 8 Apr 2020 12:03:18 +0200 Subject: [PATCH 274/284] Add readme for postgresql support --- uncloud_django_based/uncloud/doc/README-postgresql.org | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 uncloud_django_based/uncloud/doc/README-postgresql.org diff --git a/uncloud_django_based/uncloud/doc/README-postgresql.org b/uncloud_django_based/uncloud/doc/README-postgresql.org new file mode 100644 index 0000000..9e5cc10 --- /dev/null +++ b/uncloud_django_based/uncloud/doc/README-postgresql.org @@ -0,0 +1,8 @@ +* uncloud clients access the data base from a variety of outside hosts +* So the postgresql data base needs to be remotely accessible +* Instead of exposing the tcp socket, we make postgresql bind to localhost via IPv6 +** ::1, port 5432 +* Then we remotely connect to the database server with ssh tunneling +** ssh -L5432:localhost:5432 uncloud-database-host +* Configuring your database for SSH based remote access +** host all all ::1/128 trust From 3d2f8574d355a28ce7b306dd0ae51d051ba7e178 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 8 Apr 2020 13:09:17 +0200 Subject: [PATCH 275/284] [db] use tcp -> support ssh --- uncloud_django_based/uncloud/uncloud/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/uncloud_django_based/uncloud/uncloud/settings.py b/uncloud_django_based/uncloud/uncloud/settings.py index 871ac8e..9089f91 100644 --- a/uncloud_django_based/uncloud/uncloud/settings.py +++ b/uncloud_django_based/uncloud/uncloud/settings.py @@ -27,6 +27,7 @@ except ModuleNotFoundError: DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', + 'HOST': '::1', # connecting via tcp, v6, to allow ssh forwarding to work 'NAME': uncloud.secrets.POSTGRESQL_DB_NAME, } } From d3f2a3e071bfd637c0db98c4ca79aee12a5fc3a1 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Wed, 8 Apr 2020 16:24:39 +0200 Subject: [PATCH 276/284] in between commit Signed-off-by: Nico Schottelius --- uncloud_django_based/uncloud/requirements.txt | 3 + .../uncloud_net/management/commands/vpn.py | 40 ++++++--- .../uncloud/uncloud_net/models.py | 82 +++++++++++++++++-- .../uncloud/uncloud_net/serializers.py | 21 ++++- 4 files changed, 125 insertions(+), 21 deletions(-) diff --git a/uncloud_django_based/uncloud/requirements.txt b/uncloud_django_based/uncloud/requirements.txt index c7ebc65..c77db20 100644 --- a/uncloud_django_based/uncloud/requirements.txt +++ b/uncloud_django_based/uncloud/requirements.txt @@ -11,3 +11,6 @@ parsedatetime pyparsing pydot django-extensions + +# PDF creating +django-hardcopy diff --git a/uncloud_django_based/uncloud/uncloud_net/management/commands/vpn.py b/uncloud_django_based/uncloud/uncloud_net/management/commands/vpn.py index c63e5a0..6d717b8 100644 --- a/uncloud_django_based/uncloud/uncloud_net/management/commands/vpn.py +++ b/uncloud_django_based/uncloud/uncloud_net/management/commands/vpn.py @@ -13,32 +13,52 @@ log = logging.getLogger(__name__) wireguard_template=""" - [Interface] ListenPort = 51820 PrivateKey = {privatekey} +""" -# Nico, 2019-01-23, Switzerland -#[Peer] -#PublicKey = kL1S/Ipq6NkFf1MAsNRou4b9VoUsnnb4ZxgiBrH0zA8= -#AllowedIPs = 2a0a:e5c1:101::/48 +peer_template=""" +# {username} +[Peer] +PublicKey = {public_key} +AllowedIPs = {vpnnetwork} """ class Command(BaseCommand): help = 'General uncloud commands' def add_arguments(self, parser): - parser.add_argument('--hostname', action='store_true', help='Name of this VPN Host', + parser.add_argument('--hostname', + action='store_true', + help='Name of this VPN Host', required=True) def handle(self, *args, **options): -# for net if options['bootstrap']: self.bootstrap() self.create_vpn_config(options['hostname']) def create_vpn_config(self, hostname): - for pool in VPNPool.objects.filter(vpn_hostname - default_cluster = VPNNetwork.objects.get_or_create(name="default") -# local_host = + configs = [] + + for pool in VPNPool.objects.filter(vpn_hostname=hostname): + pool_config = { + 'private_key': pool.wireguard_private_key, + 'subnetwork_size': pool.subnetwork_size, + 'config_file': '/etc/wireguard/{}.conf'.format(pool.network), + 'peers': [] + } + + for vpnnetwork in VPNNetworkReservation.objects.filter(vpnpool=pool): + pool_config['peers'].append({ + 'vpnnetwork': "{}/{}".format(vpnnetwork.address, + pool_config['subnetwork_size']), + 'public_key': vpnnetwork.wireguard_public_key, + } + ) + + configs.append(pool_config) + + print(configs) diff --git a/uncloud_django_based/uncloud/uncloud_net/models.py b/uncloud_django_based/uncloud/uncloud_net/models.py index a3939ee..2eaf92d 100644 --- a/uncloud_django_based/uncloud/uncloud_net/models.py +++ b/uncloud_django_based/uncloud/uncloud_net/models.py @@ -23,30 +23,96 @@ class VPNPool(UncloudModel): network_size = models.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(128)]) - subnetwork_size = models.IntegerField(validators=[MinValueValidator(0), - MaxValueValidator(128)]) + subnetwork_size = models.IntegerField(validators=[ + MinValueValidator(0), + MaxValueValidator(128) + ]) vpn_hostname = models.CharField(max_length=256) wireguard_private_key = models.CharField(max_length=48) + @property + def num_maximum_networks(self): + """ + sample: + network_size = 40 + subnetwork_size = 48 + maximum_networks = 2^(48-40) + + 2nd sample: + network_size = 8 + subnetwork_size = 24 + maximum_networks = 2^(24-8) + """ + + return 2**(subnetwork_size - network_size) + + @property + def used_networks(self): + return self.vpnnetworkreservation_set.objects.filter(vpnpool=self, status='used') + + @property + def num_used_networks(self): + return len(self.used_networks) + + @property + def num_free_networks(self): + return self.num_maximum_networks - self.num_used_networks + + @property + def next_free_network(self): + free_net = self.vpnnetworkreservation_set.objects.filter(vpnpool=self, + status='free') + + last_net = self.vpnnetworkreservation_set.objects.filter(vpnpool=self, + status='used') + + if num_free_networks == 0: + raise Exception("No free networks") + + if len(free_net) > 0: + return free_net[0].address + + if len(used_net) > 0: + """ + sample: + + pool = 2a0a:e5c1:200::/40 + last_used = 2a0a:e5c1:204::/48 + + next: + """ + + last_ip = last_net.address +# next_ip = + + + + class VPNNetworkReservation(UncloudModel): """ - This class tracks the used VPN networks. It will be deleted, when the product is cancelled. - """ + This class tracks the used VPN networks. It will be deleted, when the product is cancelled. + """ vpnpool = models.ForeignKey(VPNPool, - on_delete=models.CASCADE) - + on_delete=models.CASCADE) address = models.GenericIPAddressField(primary_key=True) + status = models.CharField(max_length=256, + choices = ( + ('used', 'used'), + ('free', 'free') + ) + ) + class VPNNetwork(Product): """ A selected network. Used for tracking reservations / used networks """ network = models.ForeignKey(VPNNetworkReservation, - on_delete=models.CASCADE, - editable=False) + on_delete=models.CASCADE, + editable=False) wireguard_public_key = models.CharField(max_length=48) diff --git a/uncloud_django_based/uncloud/uncloud_net/serializers.py b/uncloud_django_based/uncloud/uncloud_net/serializers.py index 2c54b4f..7c7b4a2 100644 --- a/uncloud_django_based/uncloud/uncloud_net/serializers.py +++ b/uncloud_django_based/uncloud/uncloud_net/serializers.py @@ -12,7 +12,6 @@ class VPNPoolSerializer(serializers.ModelSerializer): fields = '__all__' class VPNNetworkSerializer(serializers.ModelSerializer): - class Meta: model = VPNNetwork fields = '__all__' @@ -53,8 +52,24 @@ class VPNNetworkSerializer(serializers.ModelSerializer): msg = _("No pool available for networks with size = {}. Available are: {}".format(data['network_size'], sizes)) raise serializers.ValidationError(msg) - return data def create(self, validated_data): - from_pool = + """ + Creating a new vpnnetwork - there are a couple of race conditions, + especially when run in parallel. + """ + pools = VPNPool.objects.filter(subnetwork_size=data['network_size']) + + found_pool = False + for pool in pools: + if pool.num_free_networks > 0: + found_pool = True +# address = pool. +# reservation = VPNNetworkReservation(vpnpool=pool, + + + pool = VPNPool.objects.first(subnetwork_size=data['network_size']) + + + return VPNNetwork(**validated_data) From 89c705f7d205874e9b2f04a28e465259ec9bf4b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 5 Mar 2020 15:19:25 +0100 Subject: [PATCH 277/284] Set one payment method as primary, allow updates --- .../migrations/0003_auto_20200305_1354.py | 18 +++++++++++++++ .../uncloud/uncloud_pay/models.py | 22 +++++++++---------- .../uncloud/uncloud_pay/serializers.py | 5 +++-- .../uncloud/uncloud_pay/views.py | 13 +++++++++++ 4 files changed, 45 insertions(+), 13 deletions(-) create mode 100644 uncloud_django_based/uncloud/uncloud_pay/migrations/0003_auto_20200305_1354.py diff --git a/uncloud_django_based/uncloud/uncloud_pay/migrations/0003_auto_20200305_1354.py b/uncloud_django_based/uncloud/uncloud_pay/migrations/0003_auto_20200305_1354.py new file mode 100644 index 0000000..d99ece7 --- /dev/null +++ b/uncloud_django_based/uncloud/uncloud_pay/migrations/0003_auto_20200305_1354.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.3 on 2020-03-05 13:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('uncloud_pay', '0002_auto_20200305_1524.py'), + ] + + operations = [ + migrations.AlterField( + model_name='paymentmethod', + name='primary', + field=models.BooleanField(default=False, editable=False), + ), + ] diff --git a/uncloud_django_based/uncloud/uncloud_pay/models.py b/uncloud_django_based/uncloud/uncloud_pay/models.py index 10ae985..f7aee62 100644 --- a/uncloud_django_based/uncloud/uncloud_pay/models.py +++ b/uncloud_django_based/uncloud/uncloud_pay/models.py @@ -4,9 +4,7 @@ from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator from django.utils.translation import gettext_lazy as _ from django.utils import timezone -from django.dispatch import receiver from django.core.exceptions import ObjectDoesNotExist -import django.db.models.signals as signals import uuid from functools import reduce @@ -106,7 +104,7 @@ class PaymentMethod(models.Model): ), default='stripe') description = models.TextField() - primary = models.BooleanField(default=True) + primary = models.BooleanField(default=False, editable=False) # Only used for "Stripe" source stripe_payment_method_id = models.CharField(max_length=32, blank=True, null=True) @@ -149,22 +147,24 @@ class PaymentMethod(models.Model): else: raise Exception('This payment method is unsupported/cannot be charged.') + def set_as_primary_for(self, user): + methods = PaymentMethod.objects.filter(owner=user, primary=True) + for method in methods: + print(method) + method.primary = False + method.save() + + self.primary = True + self.save() def get_primary_for(user): methods = PaymentMethod.objects.filter(owner=user) for method in methods: - # Do we want to do something with non-primary method? - if method.active and method.primary: + if method.primary: return method return None - class Meta: - # TODO: limit to one primary method per user. - # unique_together is no good since it won't allow more than one - # non-primary method. - pass - ### # Bills. diff --git a/uncloud_django_based/uncloud/uncloud_pay/serializers.py b/uncloud_django_based/uncloud/uncloud_pay/serializers.py index f408d1b..72316a6 100644 --- a/uncloud_django_based/uncloud/uncloud_pay/serializers.py +++ b/uncloud_django_based/uncloud/uncloud_pay/serializers.py @@ -20,7 +20,7 @@ class PaymentMethodSerializer(serializers.ModelSerializer): class UpdatePaymentMethodSerializer(serializers.ModelSerializer): class Meta: model = PaymentMethod - fields = ['description', 'primary'] + fields = ['description'] class ChargePaymentMethodSerializer(serializers.Serializer): amount = serializers.DecimalField(max_digits=10, decimal_places=2) @@ -29,7 +29,8 @@ class CreatePaymentMethodSerializer(serializers.ModelSerializer): please_visit = serializers.CharField(read_only=True) class Meta: model = PaymentMethod - fields = ['source', 'description', 'primary', 'please_visit'] + fields = ['uuid', 'primary', 'source', 'description', 'please_visit'] + read_only_field = ['uuid', 'primary'] ### # Orders & Products. diff --git a/uncloud_django_based/uncloud/uncloud_pay/views.py b/uncloud_django_based/uncloud/uncloud_pay/views.py index 567874d..762a3c0 100644 --- a/uncloud_django_based/uncloud/uncloud_pay/views.py +++ b/uncloud_django_based/uncloud/uncloud_pay/views.py @@ -64,6 +64,10 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + # Set newly created method as primary if no other method is. + if PaymentMethod.get_primary_for(request.user) == None: + serializer.validated_data['primary'] = True + if serializer.validated_data['source'] == "stripe": # Retrieve Stripe customer ID for user. customer_id = uncloud_stripe.get_customer_id_for(request.user) @@ -109,6 +113,7 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): @action(detail=True, methods=['get'], url_path='register-stripe-cc', renderer_classes=[TemplateHTMLRenderer]) def register_stripe_cc(self, request, pk=None): payment_method = self.get_object() + if payment_method.source != 'stripe': return Response( {'error': 'This is not a Stripe-based payment method.'}, @@ -163,6 +168,14 @@ class PaymentMethodViewSet(viewsets.ModelViewSet): error = 'Could not fetch payment method from stripe. Please try again.' return Response({'error': error}) + @action(detail=True, methods=['post'], url_path='set-as-primary') + def set_as_primary(self, request, pk=None): + payment_method = self.get_object() + payment_method.set_as_primary_for(request.user) + + serializer = self.get_serializer(payment_method) + return Response(serializer.data) + ### # Bills and Orders. From a8b81b074b03efab81db42db90a8ab6a11f57b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Wed, 8 Apr 2020 17:40:44 +0200 Subject: [PATCH 278/284] Remove user view from uncloud_pay --- uncloud_django_based/uncloud/uncloud_pay/views.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/uncloud_django_based/uncloud/uncloud_pay/views.py b/uncloud_django_based/uncloud/uncloud_pay/views.py index 762a3c0..b64981f 100644 --- a/uncloud_django_based/uncloud/uncloud_pay/views.py +++ b/uncloud_django_based/uncloud/uncloud_pay/views.py @@ -15,16 +15,6 @@ from .serializers import * from datetime import datetime import uncloud_pay.stripe as uncloud_stripe -### -# Users. - -class UserViewSet(viewsets.ReadOnlyModelViewSet): - serializer_class = UserSerializer - permission_classes = [permissions.IsAuthenticated] - - def get_queryset(self): - return get_user_model().objects.all() - ### # Payments and Payment Methods. From cc7056c87c943798ed07205ac743f9d4578878c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Wed, 8 Apr 2020 17:55:48 +0200 Subject: [PATCH 279/284] Remove old Stripe settings from secrets_sample.py --- uncloud_django_based/uncloud/uncloud/secrets_sample.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/uncloud_django_based/uncloud/uncloud/secrets_sample.py b/uncloud_django_based/uncloud/uncloud/secrets_sample.py index bc9cd38..6b0a556 100644 --- a/uncloud_django_based/uncloud/uncloud/secrets_sample.py +++ b/uncloud_django_based/uncloud/uncloud/secrets_sample.py @@ -1,6 +1,3 @@ -# Live/test key from stripe -STRIPE_KEY = '' - # XML-RPC interface of opennebula OPENNEBULA_URL = 'https://opennebula.ungleich.ch:2634/RPC2' From 08b9886ce3f83cd47d247eea34973770c611bfbc Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 9 Apr 2020 11:59:25 +0200 Subject: [PATCH 280/284] Remove sample secret key in secrets_sample No need to worry, this was just a testing key --- uncloud_django_based/uncloud/uncloud/secrets_sample.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/uncloud_django_based/uncloud/uncloud/secrets_sample.py b/uncloud_django_based/uncloud/uncloud/secrets_sample.py index 6b0a556..150fefb 100644 --- a/uncloud_django_based/uncloud/uncloud/secrets_sample.py +++ b/uncloud_django_based/uncloud/uncloud/secrets_sample.py @@ -1,3 +1,5 @@ +from django.core.management.utils import get_random_secret_key + # XML-RPC interface of opennebula OPENNEBULA_URL = 'https://opennebula.ungleich.ch:2634/RPC2' @@ -15,4 +17,5 @@ LDAP_SERVER_URI = "" STRIPE_KEY="" STRIPE_PUBLIC_KEY="" -SECRET_KEY="dx$iqt=lc&yrp^!z5$ay^%g5lhx1y3bcu=jg(jx0yj0ogkfqvf" +# The django secret key +SECRET_KEY=get_random_secret_key() From 7d892daff9a902a5218b2330ac3674481dd16904 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 9 Apr 2020 11:59:49 +0200 Subject: [PATCH 281/284] [db] stay on psql+socket --- uncloud_django_based/uncloud/uncloud/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud_django_based/uncloud/uncloud/settings.py b/uncloud_django_based/uncloud/uncloud/settings.py index 9089f91..d05252e 100644 --- a/uncloud_django_based/uncloud/uncloud/settings.py +++ b/uncloud_django_based/uncloud/uncloud/settings.py @@ -27,7 +27,7 @@ except ModuleNotFoundError: DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', - 'HOST': '::1', # connecting via tcp, v6, to allow ssh forwarding to work +# 'HOST': '::1', # connecting via tcp, v6, to allow ssh forwarding to work 'NAME': uncloud.secrets.POSTGRESQL_DB_NAME, } } From cb3346303bc69e9f7467a3cfdb0a6a8cddafbf6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 9 Apr 2020 12:06:05 +0200 Subject: [PATCH 282/284] Fix typo in migration dependencies for uncloud_pay --- .../uncloud/uncloud_pay/migrations/0003_auto_20200305_1354.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncloud_django_based/uncloud/uncloud_pay/migrations/0003_auto_20200305_1354.py b/uncloud_django_based/uncloud/uncloud_pay/migrations/0003_auto_20200305_1354.py index d99ece7..4157732 100644 --- a/uncloud_django_based/uncloud/uncloud_pay/migrations/0003_auto_20200305_1354.py +++ b/uncloud_django_based/uncloud/uncloud_pay/migrations/0003_auto_20200305_1354.py @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('uncloud_pay', '0002_auto_20200305_1524.py'), + ('uncloud_pay', '0002_auto_20200305_1524'), ] operations = [ From d9473e8f3328d261a298059c949d8ab7155e2bb0 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 9 Apr 2020 12:08:11 +0200 Subject: [PATCH 283/284] ++ doc --- ...E-how-to-configure-remote-uncloud-clients.org | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/uncloud_django_based/uncloud/doc/README-how-to-configure-remote-uncloud-clients.org b/uncloud_django_based/uncloud/doc/README-how-to-configure-remote-uncloud-clients.org index 4b2b361..b685a9b 100644 --- a/uncloud_django_based/uncloud/doc/README-how-to-configure-remote-uncloud-clients.org +++ b/uncloud_django_based/uncloud/doc/README-how-to-configure-remote-uncloud-clients.org @@ -1,5 +1,19 @@ * What is a remote uncloud client? ** Systems that configure themselves for the use with uncloud -** Examples are VMHosts, VPN Servers, etc. +** Examples are VMHosts, VPN Servers, cdist control server, etc. * Which access do these clients need? ** They need read / write access to the database +* Possible methods +** Overview +| | pros | cons | +| SSL based | Once setup, can access all django parts natively, locally | X.509 infrastructure | +| SSH -L tunnel | All nodes can use [::1]:5432 | SSH setup can be fragile | +| ssh djangohost manage.py | All DB ops locally | Code is only executed on django host | +| https + token | Rest alike / consistent access | Code is only executed on django host | +** remote vs. local Django code execution + - If manage.py is executed locally (= on the client), it can + check/modify local configs + - However local execution requires a pyvenv + packages + db access + - Remote execution (= on the primary django host) can acess the db + via unix socket + - However remote execution cannot check local state From 9431f11284f43f6cb665cfe7acb0a890258cf924 Mon Sep 17 00:00:00 2001 From: Nico Schottelius Date: Thu, 9 Apr 2020 12:09:38 +0200 Subject: [PATCH 284/284] ++notes --- .../doc/README-how-to-configure-remote-uncloud-clients.org | 2 ++ 1 file changed, 2 insertions(+) diff --git a/uncloud_django_based/uncloud/doc/README-how-to-configure-remote-uncloud-clients.org b/uncloud_django_based/uncloud/doc/README-how-to-configure-remote-uncloud-clients.org index b685a9b..7217e1f 100644 --- a/uncloud_django_based/uncloud/doc/README-how-to-configure-remote-uncloud-clients.org +++ b/uncloud_django_based/uncloud/doc/README-how-to-configure-remote-uncloud-clients.org @@ -14,6 +14,8 @@ - If manage.py is executed locally (= on the client), it can check/modify local configs - However local execution requires a pyvenv + packages + db access + - Local execution also *could* make use of postgresql notify for + triggering actions (which is quite neat) - Remote execution (= on the primary django host) can acess the db via unix socket - However remote execution cannot check local state