From 1f30fd3d43f5715fc44cfe5a28afea8f1111848a Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Sat, 1 Jun 2024 16:06:05 +0530 Subject: [PATCH] feat(hwil): implement command to manage HWIL devices This commit introduces the command to manage hardware-in-loop devices. Usage: rio hwil [OPTIONS] COMMAND [ARGS]... Manage Hardware-in-the-Loop (HWIL) devices Options: --help Show this message and exit. Commands: create Create a new hardware-in-the-loop device. delete Delete one or more devices execute Execute inspect Inspect the hardware-in-the-loop device. list Lists hardware-in-loop devices. login Log in to HWIL. ssh SSH into the hardware-in-the-loop device. --- Pipfile | 1 + Pipfile.lock | 175 +++++++++++++++++++++- docs/source/hwil.rst | 10 ++ docs/source/index.rst | 1 + riocli/bootstrap.py | 2 + riocli/config/__init__.py | 7 + riocli/config/config.py | 15 +- riocli/exceptions/__init__.py | 8 + riocli/hwil/__init__.py | 45 ++++++ riocli/hwil/create.py | 74 ++++++++++ riocli/hwil/delete.py | 79 ++++++++++ riocli/hwil/execute.py | 42 ++++++ riocli/hwil/inspect.py | 49 +++++++ riocli/hwil/list.py | 48 ++++++ riocli/hwil/login.py | 96 ++++++++++++ riocli/hwil/ssh.py | 39 +++++ riocli/hwil/util.py | 92 ++++++++++++ riocli/hwilclient/__init__.py | 1 + riocli/hwilclient/client.py | 221 ++++++++++++++++++++++++++++ riocli/utils/__init__.py | 5 + riocli/utils/ssh_shell.py | 266 ++++++++++++++++++++++++++++++++++ setup.py | 1 + 22 files changed, 1268 insertions(+), 9 deletions(-) create mode 100644 docs/source/hwil.rst create mode 100644 riocli/hwil/__init__.py create mode 100644 riocli/hwil/create.py create mode 100644 riocli/hwil/delete.py create mode 100644 riocli/hwil/execute.py create mode 100644 riocli/hwil/inspect.py create mode 100644 riocli/hwil/list.py create mode 100644 riocli/hwil/login.py create mode 100644 riocli/hwil/ssh.py create mode 100644 riocli/hwil/util.py create mode 100644 riocli/hwilclient/__init__.py create mode 100644 riocli/hwilclient/client.py create mode 100644 riocli/utils/ssh_shell.py diff --git a/Pipfile b/Pipfile index 48bf9d83..64e7b46f 100644 --- a/Pipfile +++ b/Pipfile @@ -37,6 +37,7 @@ python-benedict = ">=0.33.2" etcd3gw = ">=2.4.0" graphviz = ">=0.20.3" python-magic = ">=0.4.27" +paramiko = "3.4.0" [requires] python_version = "3" diff --git a/Pipfile.lock b/Pipfile.lock index 84aca260..8b763441 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "a0d65286d616e25dd243f5ab007b77664bbb0c71a6065d9e34851f4c73ed4261" + "sha256": "7bacb695d20153554c293689753f9eaeb88cef9fb75728ed5ca01d28c216c629" }, "pipfile-spec": 6, "requires": { @@ -32,6 +32,39 @@ "markers": "python_version >= '3.7'", "version": "==23.2.0" }, + "bcrypt": { + "hashes": [ + "sha256:01746eb2c4299dd0ae1670234bf77704f581dd72cc180f444bfe74eb80495b64", + "sha256:037c5bf7c196a63dcce75545c8874610c600809d5d82c305dd327cd4969995bf", + "sha256:094fd31e08c2b102a14880ee5b3d09913ecf334cd604af27e1013c76831f7b05", + "sha256:0d4cf6ef1525f79255ef048b3489602868c47aea61f375377f0d00514fe4a78c", + "sha256:193bb49eeeb9c1e2db9ba65d09dc6384edd5608d9d672b4125e9320af9153a15", + "sha256:2505b54afb074627111b5a8dc9b6ae69d0f01fea65c2fcaea403448c503d3991", + "sha256:2ee15dd749f5952fe3f0430d0ff6b74082e159c50332a1413d51b5689cf06623", + "sha256:31adb9cbb8737a581a843e13df22ffb7c84638342de3708a98d5c986770f2834", + "sha256:3a5be252fef513363fe281bafc596c31b552cf81d04c5085bc5dac29670faa08", + "sha256:3d3b317050a9a711a5c7214bf04e28333cf528e0ed0ec9a4e55ba628d0f07c1a", + "sha256:48429c83292b57bf4af6ab75809f8f4daf52aa5d480632e53707805cc1ce9b74", + "sha256:4a8bea4c152b91fd8319fef4c6a790da5c07840421c2b785084989bf8bbb7455", + "sha256:4fb253d65da30d9269e0a6f4b0de32bd657a0208a6f4e43d3e645774fb5457f3", + "sha256:551b320396e1d05e49cc18dd77d970accd52b322441628aca04801bbd1d52a73", + "sha256:5f7cd3399fbc4ec290378b541b0cf3d4398e4737a65d0f938c7c0f9d5e686611", + "sha256:6004f5229b50f8493c49232b8e75726b568535fd300e5039e255d919fc3a07f2", + "sha256:6717543d2c110a155e6821ce5670c1f512f602eabb77dba95717ca76af79867d", + "sha256:6cac78a8d42f9d120b3987f82252bdbeb7e6e900a5e1ba37f6be6fe4e3848286", + "sha256:8a893d192dfb7c8e883c4576813bf18bb9d59e2cfd88b68b725990f033f1b978", + "sha256:8cbb119267068c2581ae38790e0d1fbae65d0725247a930fc9900c285d95725d", + "sha256:9f8ea645eb94fb6e7bea0cf4ba121c07a3a182ac52876493870033141aa687bc", + "sha256:c4c8d9b3e97209dd7111bf726e79f638ad9224b4691d1c7cfefa571a09b1b2d6", + "sha256:cb9c707c10bddaf9e5ba7cdb769f3e889e60b7d4fea22834b261f51ca2b89fed", + "sha256:d84702adb8f2798d813b17d8187d27076cca3cd52fe3686bb07a9083930ce650", + "sha256:ec3c2e1ca3e5c4b9edb94290b356d082b721f3f50758bce7cce11d8a7c89ce84", + "sha256:f44a97780677e7ac0ca393bd7982b19dbbd8d7228c1afe10b128fd9550eef5f1", + "sha256:f5698ce5292a4e4b9e5861f7e53b1d89242ad39d54c3da451a93cac17b61921a" + ], + "markers": "python_version >= '3.7'", + "version": "==4.1.3" + }, "certifi": { "hashes": [ "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516", @@ -40,6 +73,64 @@ "markers": "python_version >= '3.6'", "version": "==2024.6.2" }, + "cffi": { + "hashes": [ + "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc", + "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a", + "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417", + "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab", + "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520", + "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36", + "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743", + "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8", + "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed", + "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684", + "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56", + "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324", + "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d", + "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235", + "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e", + "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088", + "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000", + "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7", + "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e", + "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673", + "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c", + "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe", + "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2", + "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098", + "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8", + "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a", + "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0", + "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b", + "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896", + "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e", + "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9", + "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2", + "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b", + "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6", + "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404", + "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f", + "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0", + "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4", + "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc", + "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936", + "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba", + "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872", + "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb", + "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614", + "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1", + "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d", + "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969", + "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b", + "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4", + "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627", + "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956", + "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357" + ], + "markers": "platform_python_implementation != 'PyPy'", + "version": "==1.16.0" + }, "charset-normalizer": { "hashes": [ "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", @@ -191,6 +282,44 @@ "markers": "python_version >= '3.6'", "version": "==0.4.6" }, + "cryptography": { + "hashes": [ + "sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad", + "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583", + "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b", + "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c", + "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1", + "sha256:343728aac38decfdeecf55ecab3264b015be68fc2816ca800db649607aeee648", + "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949", + "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba", + "sha256:5a94eccb2a81a309806027e1670a358b99b8fe8bfe9f8d329f27d72c094dde8c", + "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9", + "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d", + "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c", + "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e", + "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2", + "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d", + "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7", + "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70", + "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2", + "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7", + "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14", + "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe", + "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e", + "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71", + "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961", + "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7", + "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c", + "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28", + "sha256:dec9b018df185f08483f294cae6ccac29e7a6e0678996587363dc352dc65c842", + "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902", + "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801", + "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a", + "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e" + ], + "markers": "python_version >= '3.7'", + "version": "==42.0.8" + }, "dictdiffer": { "hashes": [ "sha256:17bacf5fbfe613ccf1b6d512bd766e6b21fb798822a133aa86098b8ac9997578", @@ -352,6 +481,14 @@ "index": "pypi", "version": "==4.0.0" }, + "paramiko": { + "hashes": [ + "sha256:43f0b51115a896f9c00f59618023484cb3a14b98bbceab43394a39c6739b7ee7", + "sha256:aac08f26a31dc4dffd92821527d1682d99d52f9ef6851968114a8728f3c274d3" + ], + "index": "pypi", + "version": "==3.4.0" + }, "pbr": { "hashes": [ "sha256:4a7317d5e3b17a3dccb6a8cfe67dab65b20551404c52c8ed41279fa4f0cb4cda", @@ -370,11 +507,35 @@ }, "prompt-toolkit": { "hashes": [ - "sha256:07c60ee4ab7b7e90824b61afa840c8f5aad2d46b3e2e10acc33d8ecc94a49089", - "sha256:a29b89160e494e3ea8622b09fa5897610b437884dcdcd054fdc1308883326c2a" + "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10", + "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360" ], "markers": "python_full_version >= '3.7.0'", - "version": "==3.0.45" + "version": "==3.0.47" + }, + "pycparser": { + "hashes": [ + "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", + "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc" + ], + "markers": "python_version >= '3.8'", + "version": "==2.22" + }, + "pynacl": { + "hashes": [ + "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", + "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", + "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", + "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", + "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", + "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", + "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", + "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", + "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", + "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543" + ], + "markers": "python_version >= '3.6'", + "version": "==1.5.0" }, "pyrfc3339": { "hashes": [ @@ -1000,11 +1161,11 @@ "validation" ], "hashes": [ - "sha256:26b0ec1ee16c1bcab719a69c5717a146cc4a100d5e7311f9b57c41d7181a35bf", - "sha256:9582f77ef8a74aa2a84e872652309083d5d6139a7c44272555f3bdeeb706669a" + "sha256:0e9898513eacbcf06c6b05e9e042a7733cfb2030335532044b9b3ff84431821c", + "sha256:65cea1259d69339e518481c9f59130cea2a6f712117bee340bc4c1c10e47f9e7" ], "markers": "python_version >= '3.7'", - "version": "==2.0.2" + "version": "==2.1.0" }, "pygments": { "hashes": [ diff --git a/docs/source/hwil.rst b/docs/source/hwil.rst new file mode 100644 index 00000000..bc25bf7a --- /dev/null +++ b/docs/source/hwil.rst @@ -0,0 +1,10 @@ +Hardware-in-Loop +================ + +.. toctree:: + :maxdepth: 3 + :caption: Contents: + +.. click:: riocli.hwil:hwildevice + :prog: rio hwil + :nested: full diff --git a/docs/source/index.rst b/docs/source/index.rst index c8698a5c..4bde6f42 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -65,6 +65,7 @@ Rapyuta CLI has commands for all rapyuta.io resources. You can read more about t Deployment Device Disk + Hardware-in-Loop ManagedService Network Organization diff --git a/riocli/bootstrap.py b/riocli/bootstrap.py index 2af4c156..3531150c 100644 --- a/riocli/bootstrap.py +++ b/riocli/bootstrap.py @@ -28,6 +28,7 @@ from riocli.completion import completion from riocli.config import Configuration from riocli.configtree import config_trees +from riocli.hwil import hwildevice from riocli.constants import Colors, Symbols from riocli.deployment import deployment from riocli.device import device @@ -138,3 +139,4 @@ def update(silent: bool) -> None: cli.add_command(vpn) cli.add_command(usergroup) cli.add_command(config_trees) +cli.add_command(hwildevice) \ No newline at end of file diff --git a/riocli/config/__init__.py b/riocli/config/__init__.py index 84e70d02..c3da6ee6 100644 --- a/riocli/config/__init__.py +++ b/riocli/config/__init__.py @@ -39,6 +39,13 @@ def new_v2_client(config_inst: Configuration = None, with_project: bool = True): return config_inst.new_v2_client(with_project=with_project) +def new_hwil_client(config_inst: Configuration = None): + if not config_inst: + config_inst = Configuration() + + return config_inst.new_hwil_client() + + def get_config_from_context(ctx: click.Context) -> Configuration: config_obj = ctx.obj diff --git a/riocli/config/config.py b/riocli/config/config.py index d1ecd9b1..204935d2 100644 --- a/riocli/config/config.py +++ b/riocli/config/config.py @@ -23,7 +23,8 @@ from click import get_app_dir from rapyuta_io import Client -from riocli.exceptions import LoggedOut, NoOrganizationSelected, NoProjectSelected +from riocli.exceptions import LoggedOut, NoOrganizationSelected, NoProjectSelected, HwilLoggedOut +from riocli.hwilclient import Client as HwilClient from riocli.v2client import Client as v2Client @@ -48,7 +49,6 @@ def __init__(self, filepath: Optional[str] = None): self._filepath = filepath self.exists = True - # If config file does not exist, then initialize an empty dictionary instead. if not os.path.exists(self.filepath): self.exists = False @@ -109,6 +109,17 @@ def new_v2_client(self: Configuration, with_project: bool = True) -> v2Client: return v2Client(self, auth_token=token, project=project) + def new_hwil_client(self: Configuration) -> HwilClient: + if 'hwil_auth_token' not in self.data: + raise HwilLoggedOut + + if 'environment' in self.data: + os.environ['RIO_CONFIG'] = self.filepath + + token = self.data.get('hwil_auth_token', None) + + return HwilClient(auth_token=token) + def get_auth_header(self: Configuration) -> dict: if not ('auth_token' in self.data and 'project_id' in self.data): raise LoggedOut diff --git a/riocli/exceptions/__init__.py b/riocli/exceptions/__init__.py index 33877e1d..57950ddd 100644 --- a/riocli/exceptions/__init__.py +++ b/riocli/exceptions/__init__.py @@ -34,3 +34,11 @@ def __str__(self): return """Not logged in. Please login first $ rio auth login """ + + +class HwilLoggedOut(Exception): + + def __str__(self): + return """Not logged in to HWIL. Please login first + $ rio hwil login + """ diff --git a/riocli/hwil/__init__.py b/riocli/hwil/__init__.py new file mode 100644 index 00000000..ddb2c866 --- /dev/null +++ b/riocli/hwil/__init__.py @@ -0,0 +1,45 @@ +# Copyright 2024 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import click +from click_help_colors import HelpColorsGroup + +from riocli.constants import Colors +from riocli.hwil.create import create_device +from riocli.hwil.delete import delete_device +from riocli.hwil.execute import execute +from riocli.hwil.inspect import inspect_device +from riocli.hwil.list import list_devices +from riocli.hwil.login import login +from riocli.hwil.ssh import ssh + + +@click.group( + name="hwil", + invoke_without_command=False, + cls=HelpColorsGroup, + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, +) +def hwildevice(): + """Manage Hardware-in-the-Loop (HWIL) devices""" + pass + + +hwildevice.add_command(login) +hwildevice.add_command(create_device) +hwildevice.add_command(list_devices) +hwildevice.add_command(delete_device) +hwildevice.add_command(inspect_device) +hwildevice.add_command(execute) +hwildevice.add_command(ssh) diff --git a/riocli/hwil/create.py b/riocli/hwil/create.py new file mode 100644 index 00000000..91edf7fa --- /dev/null +++ b/riocli/hwil/create.py @@ -0,0 +1,74 @@ +# Copyright 2024 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import typing + +import click +from click_help_colors import HelpColorsCommand +from yaspin.api import Yaspin + +from riocli.config import new_hwil_client +from riocli.constants import Colors, Symbols +from riocli.utils.spinner import with_spinner + + +@click.command( + 'create', + cls=HelpColorsCommand, + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, +) +@click.option('--arch', 'arch', help='Device architecture', + type=click.Choice(['amd64', 'arm64']), default='amd64') +@click.option('--os', 'os', help='Type of the OS', + type=click.Choice(['debian', 'ubuntu']), default='ubuntu') +@click.option('--codename', 'codename', help='Code name of the OS', + type=click.Choice(['bionic', 'focal', 'jammy', 'bullseye']), default='focal') +@click.argument('device-name', type=str) +@with_spinner(text='Creating device...') +@click.pass_context +def create_device( + ctx: click.Context, + device_name: str, + arch: str, + os: str, + codename: str, + spinner: Yaspin = None, +) -> None: + """Create a new hardware-in-the-loop device.""" + info = click.style(f'{Symbols.INFO} Device configuration = {os}:{codename}:{arch}', + fg=Colors.CYAN, bold=True) + spinner.write(info) + client = new_hwil_client() + labels = prepare_device_labels_from_context(ctx) + + try: + client.create_device(device_name, arch, os, codename, labels) + spinner.text = click.style(f'Device {device_name} created successfully.', fg=Colors.GREEN) + spinner.green.ok(Symbols.SUCCESS) + except Exception as e: + spinner.text = click.style(f'Failed to create device: {str(e)}', fg=Colors.RED) + spinner.red.fail(Symbols.ERROR) + raise SystemExit(1) + + +def prepare_device_labels_from_context(ctx: click.Context) -> typing.Dict: + user_email = ctx.obj.data.get('email_id', '') + if user_email: + user_email = user_email.split('@')[0] + + return { + "user": user_email, + "organization": ctx.obj.data.get('organization_id', ''), + "project": ctx.obj.data.get('project_id', ''), + } diff --git a/riocli/hwil/delete.py b/riocli/hwil/delete.py new file mode 100644 index 00000000..ad967dc0 --- /dev/null +++ b/riocli/hwil/delete.py @@ -0,0 +1,79 @@ +# Copyright 2024 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import typing + +import click +from click_help_colors import HelpColorsCommand +from yaspin.api import Yaspin + +from riocli.config import new_hwil_client +from riocli.constants import Colors, Symbols +from riocli.utils.spinner import with_spinner + + +@click.command( + 'delete', + cls=HelpColorsCommand, + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, +) +@click.argument('devices', type=str, nargs=-1) +@click.option('--force', '-f', '--silent', 'force', is_flag=True, + default=False, help='Skip confirmation') +@with_spinner(text='Deleting device(s)...') +def delete_device( + devices: typing.List, + force: bool, + spinner: Yaspin = None, +) -> None: + """Delete one or more devices""" + + if not devices: + spinner.text = click.style('No device names provided', fg=Colors.RED) + spinner.red.fail(Symbols.ERROR) + raise SystemExit(1) + + client = new_hwil_client() + fetched = [] + + try: + fetched = client.list_devices() + except Exception as e: + spinner.text = click.style(f'Error fetching device(s): {str(e)}', fg=Colors.RED) + spinner.red.fail(Symbols.ERROR) + + device_name_map = {name: None for name in devices} + + final = {d['id']: d['name'] for d in fetched + if d['name'] in device_name_map} + + if not final: + spinner.text = click.style(f'No devices found with name(s): {", ".join(devices)}', fg=Colors.RED) + spinner.red.fail(Symbols.ERROR) + raise SystemExit(1) + + with spinner.hidden(): + if not force: + click.confirm(f'Do you want to delete {", ".join(final.values())}?', abort=True) + + try: + for device_id, device_name in final.items(): + spinner.text = f'Deleting device {device_name}...' + client.delete_device(device_id) + spinner.text = click.style(f'Device(s) deleted successfully!', fg=Colors.GREEN) + spinner.green.ok(Symbols.SUCCESS) + except Exception as e: + spinner.text = click.style(f'Error deleting device(s): {str(e)}', fg=Colors.RED) + spinner.red.fail(Symbols.ERROR) + raise SystemExit(1) diff --git a/riocli/hwil/execute.py b/riocli/hwil/execute.py new file mode 100644 index 00000000..54edc77a --- /dev/null +++ b/riocli/hwil/execute.py @@ -0,0 +1,42 @@ +# Copyright 2024 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import sys + +import click +from click_help_colors import HelpColorsCommand + +from riocli.config import new_hwil_client +from riocli.constants import Colors +from riocli.hwil.util import name_to_id, execute_command + + +@click.command( + 'execute', + cls=HelpColorsCommand, + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, +) +@click.argument('device-name', required=True, type=str) +@click.argument('command', required=True, type=str) +@name_to_id +def execute(device_name: str, device_id: str, command: str) -> None: + """Execute a command on a hardware-in-the-loop device.""" + try: + code, stdout, stderr = execute_command(new_hwil_client(), device_id, command) + sys.stdout.write(stdout) + sys.stderr.write(stderr) + sys.exit(code) + except Exception as e: + click.secho(str(e), fg=Colors.RED) + raise SystemExit(1) diff --git a/riocli/hwil/inspect.py b/riocli/hwil/inspect.py new file mode 100644 index 00000000..a38e536e --- /dev/null +++ b/riocli/hwil/inspect.py @@ -0,0 +1,49 @@ +# Copyright 2024 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import click +from click_help_colors import HelpColorsCommand +from munch import unmunchify + +from riocli.config import new_hwil_client +from riocli.constants import Colors +from riocli.hwil.util import name_to_id +from riocli.utils import inspect_with_format + + +@click.command( + 'inspect', + cls=HelpColorsCommand, + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, +) +@click.option('--format', '-f', 'format_type', default='yaml', + type=click.Choice(['json', 'yaml'], case_sensitive=False)) +@click.argument('device-name', type=str) +@name_to_id +def inspect_device( + format_type: str, + device_name: str, + device_id: str +) -> None: + """ + Inspect the hardware-in-the-loop device. + """ + client = new_hwil_client() + + try: + device = client.get_device(device_id) + inspect_with_format(unmunchify(device), format_type) + except Exception as e: + click.secho(str(e), fg=Colors.RED) diff --git a/riocli/hwil/list.py b/riocli/hwil/list.py new file mode 100644 index 00000000..429c046f --- /dev/null +++ b/riocli/hwil/list.py @@ -0,0 +1,48 @@ +# Copyright 2024 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import typing + +import click +from click_help_colors import HelpColorsCommand + +from riocli.config import new_hwil_client +from riocli.constants import Colors +from riocli.utils import tabulate_data + + +@click.command( + 'list', + cls=HelpColorsCommand, + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, +) +def list_devices() -> None: + """Lists hardware-in-loop devices.""" + try: + devices = new_hwil_client().list_devices() + devices = sorted(devices, key=lambda d: d.name.lower()) + _display_device_list(devices, show_header=True) + except Exception as e: + click.secho(str(e), fg=Colors.RED) + raise SystemExit(1) + + +def _display_device_list(devices: typing.List[dict], show_header: bool = True) -> None: + headers = [] + if show_header: + headers = ('ID', 'Name', 'Status', 'Static IP', 'Dynamic IP', 'Flavor') + + data = [[d.id, d.name, d.status, d.static_ip, d.ip_address, d.flavor] for d in devices] + + tabulate_data(data, headers) diff --git a/riocli/hwil/login.py b/riocli/hwil/login.py new file mode 100644 index 00000000..e53a1970 --- /dev/null +++ b/riocli/hwil/login.py @@ -0,0 +1,96 @@ +# Copyright 2024 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +from base64 import b64encode + +import click +from click_help_colors import HelpColorsCommand +from rapyuta_io.utils import UnauthorizedError + +from riocli.constants import Colors, Symbols +from riocli.hwilclient import Client as HwilClient +from riocli.utils.context import get_root_context +from riocli.utils.spinner import with_spinner + +HWIL_LOGIN_SUCCESS = click.style('{} Successfully logged into HWIL!'.format(Symbols.SUCCESS), fg=Colors.GREEN) + + +@click.command( + 'login', + cls=HelpColorsCommand, + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, +) +@click.option('--username', help='Username for HWIL API') +@click.option('--password', help='Password for HWIL API') +@click.option('--interactive/--no-interactive', '--interactive/--silent', + is_flag=True, type=bool, default=True, + help='Make login interactive') +@click.pass_context +def login( + ctx: click.Context, + username: str, + password: str, + interactive: bool = True, +) -> None: + """Log in to HWIL.""" + ctx = get_root_context(ctx) + + if interactive: + username = username or click.prompt('Username') + password = password or click.prompt('Password', hide_input=True) + + if not username: + click.secho(f'{Symbols.ERROR} Username not specified', fg=Colors.RED) + raise SystemExit(1) + + if not password: + click.secho(f'{Symbols.ERROR} Password not specified', fg=Colors.RED) + raise SystemExit(1) + + try: + validate_and_set_hwil_token(ctx, username, password) + except Exception as e: + raise SystemExit(1) from e + + +@with_spinner(text='Validating credentials...') +def validate_and_set_hwil_token( + ctx: click.Context, + username: str, + password: str, + spinner=None +) -> None: + """Validates an auth token.""" + if 'environment' in ctx.obj.data: + os.environ['RIO_CONFIG'] = ctx.obj.filepath + + token = b64encode(f"{username}:{password}".encode('utf-8')).decode("ascii") + client = HwilClient(auth_token=token) + + try: + client.list_devices() + ctx.obj.data['hwil_auth_token'] = token + ctx.obj.save() + spinner.text = click.style('Successfully logged in.', fg=Colors.GREEN) + spinner.green.ok(Symbols.SUCCESS) + except UnauthorizedError as e: + spinner.red.text = click.style("Incorrect credentials.", fg=Colors.RED) + spinner.red.fail(Symbols.ERROR) + raise e + except Exception as e: + spinner.text = click.style(f'Failed to login: {str(e)}', fg=Colors.RED) + spinner.red.fail(Symbols.ERROR) + raise e diff --git a/riocli/hwil/ssh.py b/riocli/hwil/ssh.py new file mode 100644 index 00000000..0f9fc8dd --- /dev/null +++ b/riocli/hwil/ssh.py @@ -0,0 +1,39 @@ +# Copyright 2024 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import click +from click_help_colors import HelpColorsCommand + +from riocli.config import new_hwil_client +from riocli.constants import Colors +from riocli.hwil.util import name_to_id +from riocli.utils.ssh_shell import SSHShell + + +@click.command( + 'ssh', + cls=HelpColorsCommand, + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, +) +@click.argument('device-name', required=True, type=str) +@name_to_id +def ssh(device_name: str, device_id: str, spinner=None) -> None: + """SSH into the hardware-in-the-loop device.""" + try: + device = new_hwil_client().get_device(device_id) + SSHShell(device.static_ip, device.username, device.password).connect() + except Exception as e: + click.secho(str(e), fg=Colors.RED) + raise SystemExit(1) diff --git a/riocli/hwil/util.py b/riocli/hwil/util.py new file mode 100644 index 00000000..610b4841 --- /dev/null +++ b/riocli/hwil/util.py @@ -0,0 +1,92 @@ +# Copyright 2024 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import functools +import time +import typing + +import click + +from riocli.config import new_hwil_client +from riocli.constants import Colors +from riocli.device.util import DeviceNotFound +from riocli.hwilclient import Client + + +def name_to_id(f: typing.Callable) -> typing.Callable: + @functools.wraps(f) + def decorated(**kwargs: typing.Any): + try: + client = new_hwil_client() + except Exception as e: + click.secho(str(e), fg=Colors.RED) + raise SystemExit(1) + + name = kwargs.pop('device_name') + + # device_name is not specified + if name is None: + f(**kwargs) + return + + guid = None + if guid is None: + try: + guid = find_device_id(client, name) + except Exception as e: + click.secho(str(e), fg=Colors.RED) + raise SystemExit(1) + + kwargs['device_name'] = name + kwargs['device_id'] = guid + f(**kwargs) + + return decorated + + +def get_device(client: Client, name: str) -> str: + devices = client.list_devices() + for device in devices: + if device.name == name: + return device + + raise DeviceNotFound() + + +def find_device_id(client: Client, name: str) -> str: + devices = client.list_devices() + for device in devices: + if device.name == name: + return device.id + + raise DeviceNotFound(message="HWIL device not found") + + +def execute_command(client: Client, device_id: str, command: str) -> typing.Tuple[int, str, str]: + """Executes a command and waits for it to complete.""" + try: + response = client.execute_command(device_id, command) + except Exception as e: + raise e + + try: + while response.status == 'PENDING': + response = client.get_command(response.uuid) + time.sleep(1) + except Exception as e: + raise e + + o = response.result.output.pop() + + return o.get('rc'), o.get('stdout'), o.get('stderr') diff --git a/riocli/hwilclient/__init__.py b/riocli/hwilclient/__init__.py new file mode 100644 index 00000000..ae8c6492 --- /dev/null +++ b/riocli/hwilclient/__init__.py @@ -0,0 +1 @@ +from riocli.hwilclient.client import Client diff --git a/riocli/hwilclient/client.py b/riocli/hwilclient/client.py new file mode 100644 index 00000000..6d448048 --- /dev/null +++ b/riocli/hwilclient/client.py @@ -0,0 +1,221 @@ +# Copyright 2024 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import http +import json +import time + +import requests +from munch import Munch, munchify +from rapyuta_io.utils import ConflictError, RetriesExhausted, UnauthorizedError +from rapyuta_io.utils.rest_client import HttpMethod, RestClient + +from riocli.utils import generate_short_guid + + +def handle_server_errors(response: requests.Response): + status_code = response.status_code + + # 409 Conflict + if status_code == http.HTTPStatus.CONFLICT: + raise ConflictError('already exists') + # 401 Unauthorized + if status_code == http.HTTPStatus.UNAUTHORIZED: + raise UnauthorizedError('unauthorized access') + # 500 Internal Server Error + if status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR: + raise Exception('internal server error') + # 501 Not Implemented + if status_code == http.HTTPStatus.NOT_IMPLEMENTED: + raise Exception('not implemented') + # 502 Bad Gateway + if status_code == http.HTTPStatus.BAD_GATEWAY: + raise Exception('bad gateway') + # 503 Service Unavailable + if status_code == http.HTTPStatus.SERVICE_UNAVAILABLE: + raise Exception('service unavailable') + # 504 Gateway Timeout + if status_code == http.HTTPStatus.GATEWAY_TIMEOUT: + raise Exception('gateway timeout') + # Anything else that is not known + if status_code > 504: + raise Exception('unknown server error') + + +class Client(object): + """ + HWILv3 API Client + """ + HWIL_URL = "https://hwilv3.rapyuta.io" + ARCH_OS_DICT = { + "amd64": { + "ubuntu": { + "bionic": "ubuntu-bionic-ros-melodic-py3", + "focal": "ubuntu-focal-ros-noetic-py3", + "jammy": "ubuntu-jammy-plain-py3", + } + }, + "arm64": { + "ubuntu": { + "focal": "ubuntu-focal-ros-noetic-py3" + }, + "debian": { + "bullseye": "debian-bullseye-docker" + } + } + } + + def __init__(self, auth_token: str): + self._token = auth_token + self._host = self.HWIL_URL + + def create_device( + self: Client, + name: str, + arch: str, + os: str, + codename: str, + labels: dict = None, + ) -> Munch: + """Create a HWIL device.""" + url = f"{self._host}/device/" + headers = self._get_auth_header() + + flavor = self.ARCH_OS_DICT.get(arch, {}).get(os, {}).get(codename) + if not flavor: + raise Exception(f"image not found for {arch}:{os}:{codename}") + + labels = labels or {} + labels.update({"agent": "rapyuta-io-cli"}) + + payload = { + "kind": "VIRTUAL", + "name": name, + "architecture": arch, + "labels": labels, + "flavor": self.ARCH_OS_DICT.get(arch).get(os).get(codename) + } + + response = RestClient(url).method(HttpMethod.POST).headers( + headers).execute(payload=payload) + + handle_server_errors(response) + + data = json.loads(response.text) + if not response.ok: + err_msg = data.get('error') + raise Exception("hwil: {}".format(err_msg)) + + return munchify(data) + + def delete_device(self: Client, device_id: int) -> None: + """Delete a HWIL device.""" + url = f"{self._host}/device/{device_id}" + headers = self._get_auth_header() + response = RestClient(url).method(HttpMethod.DELETE).headers(headers).execute() + handle_server_errors(response) + + def get_device(self: Client, device_id: int) -> Munch: + """Fetch a HWIL device.""" + url = f"{self._host}/device/{device_id}" + headers = self._get_auth_header() + response = RestClient(url).method(HttpMethod.GET).headers(headers).execute() + handle_server_errors(response) + data = json.loads(response.text) + if not response.ok: + err_msg = data.get('error') + raise Exception("hwil: {}".format(err_msg)) + + return munchify(data) + + def execute_command(self: Client, device_id: int, command: str) -> Munch: + """Execute a command on the HWIL device.""" + url = f"{self._host}/command/" + headers = self._get_auth_header() + + payload = { + "kind": "VIRTUAL", + "device_id": device_id, + "command": command, + "uuid": generate_short_guid(), + } + + response = RestClient(url).method(HttpMethod.POST).headers( + headers).execute(payload=payload) + + handle_server_errors(response) + + data = json.loads(response.text) + if not response.ok: + err_msg = data.get('error') + raise Exception("hwil: {}".format(err_msg)) + + return munchify(data) + + def get_command(self: Client, command_uuid: str) -> Munch: + """Fetch a command.""" + url = f"{self._host}/command/{command_uuid}" + headers = self._get_auth_header() + response = RestClient(url).method(HttpMethod.GET).headers(headers).execute() + handle_server_errors(response) + + data = json.loads(response.text) + if not response.ok: + err_msg = data.get('error') + raise Exception("hwil: {}".format(err_msg)) + + return munchify(data) + + def poll_till_device_ready(self: Client, device_id: int, sleep_interval: int, retry_limit: int) -> None: + """Poll until HWIL device is ready""" + url = f"{self._host}/device/{device_id}" + headers = self._get_auth_header() + + for _ in range(retry_limit): + response = RestClient(url).method(HttpMethod.GET).headers(headers).execute() + + handle_server_errors(response) + + data = json.loads(response.text) + if not response.ok: + err_msg = data.get('error') + raise Exception("hwil: {}".format(err_msg)) + + device = munchify(data) + if device.status != 'IDLE': + time.sleep(sleep_interval) + continue + + return + + msg = f'Retries exhausted: Tried {retry_limit} times with {sleep_interval}s interval.' + raise RetriesExhausted(msg) + + def list_devices(self: Client): + """Fetch all HWIL devices""" + url = f"{self._host}/device/" + headers = self._get_auth_header() + response = RestClient(url).method(HttpMethod.GET).headers(headers).execute() + handle_server_errors(response) + + data = json.loads(response.text) + if not response.ok: + err_msg = data.get('error') + raise Exception("hwil: {}".format(err_msg)) + + return munchify(data) + + def _get_auth_header(self: Client) -> dict: + return dict(Authorization=f"Basic {self._token}") diff --git a/riocli/utils/__init__.py b/riocli/utils/__init__.py index 3f4769e7..0189c243 100644 --- a/riocli/utils/__init__.py +++ b/riocli/utils/__init__.py @@ -19,6 +19,7 @@ import subprocess import sys import typing +import uuid from pathlib import Path from shutil import get_terminal_size, move from tempfile import TemporaryDirectory @@ -261,3 +262,7 @@ def update_appimage(version: str): raise e except Exception as e: raise e + + +def generate_short_guid() -> str: + return uuid.uuid4().hex[:8] diff --git a/riocli/utils/ssh_shell.py b/riocli/utils/ssh_shell.py new file mode 100644 index 00000000..6ab2c923 --- /dev/null +++ b/riocli/utils/ssh_shell.py @@ -0,0 +1,266 @@ +# Source: https://github.com/paramiko/paramiko/blob/main/demos/interactive.py +# +# Copyright (C) 2003-2007 Robey Pointer +# +# This file is part of paramiko. +# +# Paramiko is free software; you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free +# Software Foundation; either version 2.1 of the License, or (at your option) +# any later version. +# +# Paramiko 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 Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Paramiko; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import os +import socket +import sys +from binascii import hexlify + +import click +import paramiko + +from riocli.constants import Colors, Symbols + +# https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Bracketed-Paste-Mode +START_PASTE = "\x1B\x5B\x32\x30\x30\x7E" # ESC[200~ +END_PASTE = "\x1B\x5B\x32\x30\x31\x7E" # ESC[201~ + +# windows does not have termios... +try: + import termios + import tty + + has_termios = True +except ImportError: + has_termios = False + + +def is_int(val: str) -> bool: + try: + int(val) + return True + except Exception: + return False + + +def interactive_shell(chan: paramiko.Channel): + if has_termios: + posix_shell(chan) + else: + windows_shell(chan) + + +def posix_readkey() -> str: + """Get a keypress. If an escaped key is pressed, the full sequence is + read and returned. + + Copied from readchar: + https://github.com/magmax/python-readchar/blob/master/readchar/_posix_read.py#L30 + """ + + c1 = sys.stdin.read(1) + + if c1 != "\x1B": # ESC + return c1 + + c2 = sys.stdin.read(1) + if c2 not in "\x4F\x5B": # O[ + return c1 + c2 + + c3 = sys.stdin.read(1) + if c3 not in "\x31\x32\x33\x35\x36": # 12356 + return c1 + c2 + c3 + + c4 = sys.stdin.read(1) + if c4 not in "\x30\x31\x33\x34\x35\x37\x38\x39": # 01345789 + return c1 + c2 + c3 + c4 + + c5 = sys.stdin.read(1) + key = c1 + c2 + c3 + c4 + c5 + + # Bracketed Paste Mode: # https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Bracketed-Paste-Mode + if key == START_PASTE[:-1] or key == END_PASTE[:-1]: + c6 = sys.stdin.read(1) + return key + c6 + + return key + + +def windows_readkey() -> str: + """Reads the next keypress. If an escaped key is pressed, the full + sequence is read and returned. + + Copied from readchar: + https://github.com/magmax/python-readchar/blob/master/readchar/_win_read.py#LL14C1-L30C24 + """ + + ch = sys.stdin.read(1) + + # if it is a normal character: + if ch not in "\x00\xe0": + return ch + + # if it is a scpeal key, read second half: + ch2 = sys.stdin.read(1) + + return "\x00" + ch2 + + +def posix_shell(chan: paramiko.Channel): # noqa: C901 + import select + + oldtty = termios.tcgetattr(sys.stdin) + + # input_history = [] + + try: + tty.setraw(sys.stdin.fileno()) + tty.setcbreak(sys.stdin.fileno()) + chan.settimeout(0.0) + while True: + r, w, e = select.select([chan, sys.stdin], [], []) + if chan in r: + try: + x = chan.recv(1024).decode() + if len(x) == 0: + sys.stdout.write("\r\n") + break + sys.stdout.write(x) + sys.stdout.flush() + except socket.timeout: + pass + if sys.stdin in r: + key = posix_readkey() + # When pasting something, we need to read the entire pasted blob at once + # Otherwise it'll hang until the next key press. + # This has to do with how 'select.select' detects changes. + # A paste is a single event of many characters, so we must handle them all as one event + if key == START_PASTE: + # Start reading the pasted text + key = posix_readkey() + # Until we reach the end of the pasted text + while key != END_PASTE: + chan.send(key) + # input_history.append(key) + key = posix_readkey() + # We've exhausted the paste event, wait for next event + continue + + if len(key) == 0: + break + chan.send(key) + # input_history.append(key) + + finally: + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, oldtty) + + # Useful in debugging how control characters were send + # print(input_history) + + +# thanks to Mike Looijmans for this code +def windows_shell(chan: paramiko.Channel): + import threading + + sys.stdout.write( + "Line-buffered terminal emulation. Press F6 or ^Z to send EOF.\r\n\r\n" + ) + + def writeall(sock): + while True: + data = sock.recv(256) + if not data: + sys.stdout.write("\r\n*** EOF ***\r\n\r\n") + sys.stdout.flush() + break + sys.stdout.write(data) + sys.stdout.flush() + + writer = threading.Thread(target=writeall, args=(chan,)) + writer.start() + + try: + while True: + d = windows_readkey() + if not d: + break + chan.send(d) + except EOFError: + # user hit ^Z or F6 + pass + + +class ConfirmAddPolicy(paramiko.client.MissingHostKeyPolicy): + """ + Policy for confirming the user if they want to add the hostname and new host key to the + local `.HostKeys` object before saving it. This is used by `.SSHClient`. + """ + + def missing_host_key(self, client, hostname, key): + click.secho(f'{Symbols.WARNING} Unknown {key.get_name()} host key for' + f' {hostname}: {hexlify(key.get_fingerprint()).decode()}', fg=Colors.YELLOW) + + should_add = click.confirm('Do you want to add this key to known_hosts?', abort=True, default=True) + + if not should_add: + raise paramiko.SSHException( + "Server {!r} not found in known_hosts".format(hostname) + ) + + client._host_keys.add(hostname, key.get_name(), key) + if client._host_keys_filename is not None: + client.save_host_keys(client._host_keys_filename) + click.secho(f'{Symbols.INFO} Added host key', fg=Colors.BLUE) + else: + click.secho(f'{Symbols.WARNING} Failed to add host key. No host key file defined!', fg=Colors.YELLOW) + + +class SSHShell: + def __init__(self, hostname: str, username: str, password: str): + self.hostname = hostname + self.username = username + self.password = password + + def connect(self): + self._open() + self._launch() + self._close() + + def _open(self): + # Get terminal size - https://stackoverflow.com/a/943921 + rows, columns = os.popen('stty size', 'r').read().split() + + ssh_client = paramiko.SSHClient() + # Set hosts key path so we can save to it + ssh_client.load_host_keys(os.path.expanduser('~/.ssh/known_hosts')) + ssh_client.set_missing_host_key_policy(ConfirmAddPolicy()) + ssh_client.connect(hostname=self.hostname, username=self.username, password=self.password) + + channel = ssh_client.get_transport().open_session() + channel.get_pty(term=os.getenv('TERM', 'xterm-256color'), width=int(columns), height=int(rows)) + channel.invoke_shell() + + self._ssh_client = ssh_client + self._channel = channel + + return channel + + def _launch(self): + interactive_shell(self._channel) + + def _close(self): + self._ssh_client.close() + + def __enter__(self): + return self._open() + + def __exit__(self, exception_type, exception_value, traceback): + self._launch() + self._close() diff --git a/setup.py b/setup.py index 80cfd37f..968c72c2 100644 --- a/setup.py +++ b/setup.py @@ -72,6 +72,7 @@ "etcd3gw>=2.4.0", "graphviz>=0.20.3", "python-magic>=0.4.27", + "paramiko==3.4.0", ], setup_requires=["flake8"], )