From b5a5049621d9a644a4c8c8589c624e88fd5925e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Nov 2023 12:29:04 +0000 Subject: [PATCH 01/13] Bump browserify-sign from 4.2.1 to 4.2.2 in /covalent_ui/webapp (#1842) * Bump browserify-sign from 4.2.1 to 4.2.2 in /covalent_ui/webapp Bumps [browserify-sign](https://github.com/crypto-browserify/browserify-sign) from 4.2.1 to 4.2.2. - [Changelog](https://github.com/browserify/browserify-sign/blob/main/CHANGELOG.md) - [Commits](https://github.com/crypto-browserify/browserify-sign/compare/v4.2.1...v4.2.2) --- updated-dependencies: - dependency-name: browserify-sign dependency-type: indirect ... Signed-off-by: dependabot[bot] * Updated changelog --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Prasy12 Co-authored-by: Sankalp Sanand --- CHANGELOG.md | 1 + covalent_ui/webapp/yarn.lock | 66 ++++++++++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 649f8f197..dde56d41d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved error handling in generate_docs.py - [Significant Changes] Migrated core server-side code to new data access layer. - Changed the way UI was accessing the qelectron database to access it directly from the mdb file in object store +- Update version of browserverify-sign ### Added diff --git a/covalent_ui/webapp/yarn.lock b/covalent_ui/webapp/yarn.lock index af5dddc50..3e684afd0 100644 --- a/covalent_ui/webapp/yarn.lock +++ b/covalent_ui/webapp/yarn.lock @@ -3128,11 +3128,16 @@ bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== -bn.js@^5.0.0, bn.js@^5.1.1: +bn.js@^5.0.0: version "5.2.0" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.0.tgz#358860674396c6997771a9d051fcc1b57d4ae002" integrity sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw== +bn.js@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" + integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== + body-parser@1.19.2: version "1.19.2" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.2.tgz#4714ccd9c157d44797b8b5607d72c0b89952f26e" @@ -3238,7 +3243,7 @@ browserify-des@^1.0.0: inherits "^2.0.1" safe-buffer "^5.1.2" -browserify-rsa@^4.0.0, browserify-rsa@^4.0.1: +browserify-rsa@^4.0.0, browserify-rsa@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.1.0.tgz#b2fd06b5b75ae297f7ce2dc651f918f5be158c8d" integrity sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog== @@ -3247,19 +3252,19 @@ browserify-rsa@^4.0.0, browserify-rsa@^4.0.1: randombytes "^2.0.1" browserify-sign@^4.0.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.2.1.tgz#eaf4add46dd54be3bb3b36c0cf15abbeba7956c3" - integrity sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg== + version "4.2.2" + resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.2.2.tgz#e78d4b69816d6e3dd1c747e64e9947f9ad79bc7e" + integrity sha512-1rudGyeYY42Dk6texmv7c4VcQ0EsvVbLwZkA+AQB7SxvXxmcD93jcHie8bzecJ+ChDlmAm2Qyu0+Ccg5uhZXCg== dependencies: - bn.js "^5.1.1" - browserify-rsa "^4.0.1" + bn.js "^5.2.1" + browserify-rsa "^4.1.0" create-hash "^1.2.0" create-hmac "^1.1.7" - elliptic "^6.5.3" + elliptic "^6.5.4" inherits "^2.0.4" - parse-asn1 "^5.1.5" - readable-stream "^3.6.0" - safe-buffer "^5.2.0" + parse-asn1 "^5.1.6" + readable-stream "^3.6.2" + safe-buffer "^5.2.1" browserify-zlib@^0.2.0: version "0.2.0" @@ -3644,6 +3649,11 @@ cliui@^6.0.0: strip-ansi "^6.0.0" wrap-ansi "^6.2.0" +clsx@^1.0.4: + version "1.2.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" + integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== + clsx@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188" @@ -4590,7 +4600,7 @@ dom-converter@^0.2.0: dependencies: utila "~0.4" -dom-helpers@^5.0.1: +dom-helpers@^5.0.1, dom-helpers@^5.1.3: version "5.2.1" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA== @@ -4721,7 +4731,7 @@ elkjs@^0.7.1: resolved "https://registry.yarnpkg.com/elkjs/-/elkjs-0.7.1.tgz#4751c5e918a4988139baf7f214e010aea22de969" integrity sha512-lD86RWdh480/UuRoHhRcnv2IMkIcK6yMDEuT8TPBIbO3db4HfnVF+1lgYdQi99Ck0yb+lg5Eb46JCHI5uOsmAw== -elliptic@^6.5.3: +elliptic@^6.5.3, elliptic@^6.5.4: version "6.5.4" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb" integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ== @@ -8502,7 +8512,7 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" -parse-asn1@^5.0.0, parse-asn1@^5.1.5: +parse-asn1@^5.0.0, parse-asn1@^5.1.6: version "5.1.6" resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.6.tgz#385080a3ec13cb62a62d39409cb3e88844cdaed4" integrity sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw== @@ -9774,6 +9784,11 @@ react-is@^17.0.1, react-is@^17.0.2: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== + react-redux@^7.2.6: version "7.2.8" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.8.tgz#a894068315e65de5b1b68899f9c6ee0923dd28de" @@ -9901,6 +9916,18 @@ react-transition-group@^4.4.2: loose-envify "^1.4.0" prop-types "^15.6.2" +react-virtualized@^9.22.5: + version "9.22.5" + resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.22.5.tgz#bfb96fed519de378b50d8c0064b92994b3b91620" + integrity sha512-YqQMRzlVANBv1L/7r63OHa2b0ZsAaDp1UhVNEdUaXI8A5u6hTpA5NYtUueLH2rFuY/27mTGIBl7ZhqFKzw18YQ== + dependencies: + "@babel/runtime" "^7.7.2" + clsx "^1.0.4" + dom-helpers "^5.1.3" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-lifecycles-compat "^3.0.4" + react@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" @@ -9950,6 +9977,15 @@ readable-stream@^3.0.6, readable-stream@^3.6.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-stream@^3.6.2: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readdirp@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" @@ -10354,7 +10390,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@^5.2.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== From 3bcebffa6fddca57e51389c0a87502cb86f3d45f Mon Sep 17 00:00:00 2001 From: Aravind <100823292+Aravind-psiog@users.noreply.github.com> Date: Thu, 23 Nov 2023 22:45:12 +0530 Subject: [PATCH 02/13] Fix for editing of Qelectron on settings page (#1858) * Bug fix on settings page for Qelectron executor * Updated changelog * Removed logs --- CHANGELOG.md | 1 + .../src/components/settings/SettingsCard.js | 104 +++++++++++------- 2 files changed, 64 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dde56d41d..de84dfdb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Changed code at line 218 in covalent/_shared_files/utils.py - Fixed usage of deprecated pydantic validation methods - Fixed qelectron_db retrieval in result object +- Fixed editability of Qelectron on settings page - UI changes ### Operations diff --git a/covalent_ui/webapp/src/components/settings/SettingsCard.js b/covalent_ui/webapp/src/components/settings/SettingsCard.js index 70c908904..b83c65227 100644 --- a/covalent_ui/webapp/src/components/settings/SettingsCard.js +++ b/covalent_ui/webapp/src/components/settings/SettingsCard.js @@ -78,6 +78,7 @@ const SettingsCard = () => { const [serverDetail, setServerDetail] = useState(null) const [tempData, setTempData] = useState(null) const [tempDataServer, setTempDataServer] = useState(null) + const [openMenuKey, setOpenMenuKey] = useState(null) useEffect(() => { dispatch(settingsResults()) @@ -169,7 +170,8 @@ const SettingsCard = () => { return childIsObject } - const handleClick = (item) => { + const handleClick = (item, args) => { + openMenuKey === args ? setOpenMenuKey(null) : setOpenMenuKey(args) _.map(item, function (value, _key) { if (_.isObject(value)) { setOpen(!open) @@ -314,13 +316,19 @@ const SettingsCard = () => { setSubMenu(Object.values(settings_result)[0].executors) } - const handleSubmenuClick = (value, key) => { + const handleSubmenuClick = (value, key, args) => { + setIsDisabled(false) - if (resultKey !== 'executors') { + setResultKey(args) + + if (!key) { setValueChange(false) - setResultKey('executors') setResultOutput(value) setRestoreData(value) + // Check if the key is 'qelectron' and scroll into view + if (key) { + document.getElementById(key).scrollIntoView({ behavior: 'smooth' }) + } } else { document.getElementById(key).scrollIntoView({ behavior: 'smooth' }) } @@ -443,7 +451,7 @@ const SettingsCard = () => { data-testid="openMenu" onClick={ isChildHasList - ? () => handleClick(menuValue) + ? () => handleClick(menuValue, menuKey) : () => {} } sx={{ @@ -452,8 +460,13 @@ const SettingsCard = () => { }} > {isChildHasList(menuValue) && ( - - {open ? ( + + menuClick(menuValue, menuKey, accName) + } + > + {menuKey === openMenuKey && menuKey !== 'sdk' ? ( ) : ( @@ -483,51 +496,60 @@ const SettingsCard = () => { /> + {openMenuKey && ( + + + {_.map(subMenu, function (value, key) { + return ( + + + + handleSubmenuClick( + subMenu, + key, + menuKey + ) + } + > + + + + + ) + })} + + + )} ) })} - - {open && ( - - - {_.map(subMenu, function (value, key) { - return ( - - - handleSubmenuClick(subMenu, key)} - > - - - - - ) - })} - - - )} - {_.map(serverDetail, function (menuValue, menuKey) { + {_.map(serverDetail, function (menuValue, menuKey, index) { return ( - + handleClick(menuValue) + ? () => handleClick(menuValue, menuKey) : () => {} } sx={{ From 0e67dfaf48954ed212e1052da24726a69028ce54 Mon Sep 17 00:00:00 2001 From: Sankalp Sanand Date: Thu, 23 Nov 2023 12:33:06 -0500 Subject: [PATCH 03/13] Stabilizing changes (#1856) * pydantic deprecation related changes * updated cloudpickle version upper limit * fixed executor_data propagation from lattice to default electrons * fixing metadata propagation to electron from lattice * updated changelog * fixed ui backend test * added fastapi min limit to be 0.100 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * running precommit hooks * added typing-extensions --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- CHANGELOG.md | 3 ++ covalent/_serialize/transport_graph.py | 2 +- covalent/_shared_files/qinfo.py | 8 ++-- covalent/_shared_files/schemas/edge.py | 4 +- covalent/_shared_files/schemas/electron.py | 4 +- covalent/_shared_files/schemas/lattice.py | 4 +- covalent/_workflow/electron.py | 1 + covalent/_workflow/lattice.py | 8 ++-- covalent/_workflow/transport.py | 6 ++- covalent/executor/qbase.py | 15 ++++--- covalent/executor/schemas.py | 4 +- covalent/quantum/qcluster/base.py | 6 +-- covalent/quantum/qcluster/simulator.py | 4 +- covalent_dispatcher/_service/models.py | 2 +- covalent_ui/api/v1/models/dispatch_model.py | 39 ++++++++----------- covalent_ui/api/v1/models/electrons_model.py | 14 +++---- covalent_ui/api/v1/models/logs_model.py | 17 ++++---- .../v1/routes/end_points/summary_routes.py | 7 ++-- requirements-client.txt | 2 +- requirements.txt | 5 ++- .../end_points/electrons_test.py | 2 +- 21 files changed, 81 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de84dfdb6..4ff08a751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed usage of deprecated pydantic validation methods - Fixed qelectron_db retrieval in result object - Fixed editability of Qelectron on settings page - UI changes +- Certain pydantic v2 related updates +- Fixed lattice's metadata propagation to electron's metadata in case no metadata was provided to the electron ### Operations @@ -45,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [Significant Changes] Migrated core server-side code to new data access layer. - Changed the way UI was accessing the qelectron database to access it directly from the mdb file in object store - Update version of browserverify-sign +- Limiting cloudpickle version to less than 3.0 for now ### Added diff --git a/covalent/_serialize/transport_graph.py b/covalent/_serialize/transport_graph.py index a7ce04eec..217c2e61c 100644 --- a/covalent/_serialize/transport_graph.py +++ b/covalent/_serialize/transport_graph.py @@ -71,7 +71,7 @@ def _deserialize_edge(e: EdgeSchema) -> dict: return { "source": e.source, "target": e.target, - "attrs": e.metadata.dict(), + "attrs": e.metadata.model_dump(), } diff --git a/covalent/_shared_files/qinfo.py b/covalent/_shared_files/qinfo.py index 92056173b..4c93bfc2f 100644 --- a/covalent/_shared_files/qinfo.py +++ b/covalent/_shared_files/qinfo.py @@ -36,11 +36,11 @@ class QNodeSpecs(BaseModel): num_trainable_params: int = None num_device_wires: int device_name: str - diff_method: Optional[str] + diff_method: Optional[str] = None expansion_strategy: str gradient_options: Dict[str, int] - interface: Optional[str] - gradient_fn: Any # can be string or `qml.gradients.gradient_transform` + interface: Optional[str] = None + gradient_fn: Any = None # can be string or `qml.gradients.gradient_transform` num_gradient_executions: Any = 0 num_parameter_shift_executions: int = None @@ -56,7 +56,7 @@ class QElectronInfo(BaseModel): device_import_path: str # used to inherit type converters and other methods device_shots: Union[ None, int, Sequence[int], Sequence[Union[int, Sequence[int]]] - ] # optional default for execution devices + ] = None # optional default for execution devices device_shots_type: Any = None device_wires: int # this can not be reliably inferred from tapes alone pennylane_active_return: bool # client-side status of `pennylane.active_return()` diff --git a/covalent/_shared_files/schemas/edge.py b/covalent/_shared_files/schemas/edge.py index 2df0b1cd7..5b7ac82b2 100644 --- a/covalent/_shared_files/schemas/edge.py +++ b/covalent/_shared_files/schemas/edge.py @@ -29,8 +29,8 @@ class EdgeMetadata(BaseModel): edge_name: str - param_type: Optional[str] - arg_index: Optional[int] + param_type: Optional[str] = None + arg_index: Optional[int] = None class EdgeSchema(BaseModel): diff --git a/covalent/_shared_files/schemas/electron.py b/covalent/_shared_files/schemas/electron.py index 3635724e8..d75500d82 100644 --- a/covalent/_shared_files/schemas/electron.py +++ b/covalent/_shared_files/schemas/electron.py @@ -19,7 +19,7 @@ from datetime import datetime from typing import Dict, Optional -from pydantic import BaseModel, validator +from pydantic import BaseModel, field_validator from .asset import AssetSchema from .common import StatusEnum @@ -122,7 +122,7 @@ class ElectronSchema(BaseModel): assets: ElectronAssets custom_assets: Optional[Dict[str, AssetSchema]] = None - @validator("custom_assets") + @field_validator("custom_assets") def check_custom_asset_keys(cls, v): if v is not None: for key in v: diff --git a/covalent/_shared_files/schemas/lattice.py b/covalent/_shared_files/schemas/lattice.py index 1bc7f4da2..80a8c62aa 100644 --- a/covalent/_shared_files/schemas/lattice.py +++ b/covalent/_shared_files/schemas/lattice.py @@ -18,7 +18,7 @@ from typing import Dict, Optional -from pydantic import BaseModel, validator +from pydantic import BaseModel, field_validator from .asset import AssetSchema from .transport_graph import TransportGraphSchema @@ -115,7 +115,7 @@ class LatticeSchema(BaseModel): transport_graph: TransportGraphSchema - @validator("custom_assets") + @field_validator("custom_assets") def check_custom_asset_keys(cls, v): if v is not None: for key in v: diff --git a/covalent/_workflow/electron.py b/covalent/_workflow/electron.py index 67f3044ba..ebe313675 100644 --- a/covalent/_workflow/electron.py +++ b/covalent/_workflow/electron.py @@ -16,6 +16,7 @@ """Class corresponding to computation nodes.""" + import inspect import json import operator diff --git a/covalent/_workflow/lattice.py b/covalent/_workflow/lattice.py index aa09ac033..2c4159d99 100644 --- a/covalent/_workflow/lattice.py +++ b/covalent/_workflow/lattice.py @@ -203,7 +203,7 @@ def build_graph(self, *args, **kwargs) -> None: named_args, named_kwargs = get_named_params(workflow_function, args, kwargs) new_args = [v for _, v in named_args.items()] - new_kwargs = {k: v for k, v in named_kwargs.items()} + new_kwargs = dict(named_kwargs.items()) self.inputs = TransportableObject({"args": args, "kwargs": kwargs}) self.named_args = TransportableObject(named_args) @@ -215,7 +215,7 @@ def build_graph(self, *args, **kwargs) -> None: new_metadata = { name: DEFAULT_METADATA_VALUES[name] for name in constraint_names - if not self.metadata[name] + if self.metadata[name] is None } new_metadata = encode_metadata(new_metadata) @@ -330,8 +330,8 @@ def lattice( # Add custom metadata fields here deps_bash: Union[DepsBash, list, str] = None, deps_pip: Union[DepsPip, list] = None, - call_before: Union[List[DepsCall], DepsCall] = [], - call_after: Union[List[DepsCall], DepsCall] = [], + call_before: Union[List[DepsCall], DepsCall] = None, + call_after: Union[List[DepsCall], DepsCall] = None, triggers: Union["BaseTrigger", List["BaseTrigger"]] = None, # e.g. schedule: True, whether to use a custom scheduling logic or not ) -> Lattice: diff --git a/covalent/_workflow/transport.py b/covalent/_workflow/transport.py index 191789580..c1c7dd31d 100644 --- a/covalent/_workflow/transport.py +++ b/covalent/_workflow/transport.py @@ -37,7 +37,7 @@ def encode_metadata(metadata: dict) -> dict: encoded_metadata = deepcopy(metadata) if "executor" in metadata: if "executor_data" not in metadata: - encoded_metadata["executor_data"] = {} + encoded_metadata["executor_data"] = None if metadata["executor"] is None else {} if metadata["executor"] is not None and not isinstance(metadata["executor"], str): encoded_executor = metadata["executor"].to_dict() encoded_metadata["executor"] = encoded_executor["short_name"] @@ -45,7 +45,9 @@ def encode_metadata(metadata: dict) -> dict: if "workflow_executor" in metadata: if "workflow_executor_data" not in metadata: - encoded_metadata["workflow_executor_data"] = {} + encoded_metadata["workflow_executor_data"] = ( + None if metadata["workflow_executor"] is None else {} + ) if metadata["workflow_executor"] is not None and not isinstance( metadata["workflow_executor"], str ): diff --git a/covalent/executor/qbase.py b/covalent/executor/qbase.py index ca04a1c53..cc3e55144 100644 --- a/covalent/executor/qbase.py +++ b/covalent/executor/qbase.py @@ -25,7 +25,12 @@ import orjson import pennylane as qml from mpire import WorkerPool -from pydantic import BaseModel, Extra, Field, root_validator # pylint: disable=no-name-in-module +from pydantic import ( # pylint: disable=no-name-in-module + BaseModel, + ConfigDict, + Field, + model_validator, +) from .._shared_files.qinfo import QElectronInfo, QNodeSpecs @@ -109,10 +114,10 @@ def override_shots(self) -> Union[int, None]: # User has specified `shots` as an int. return self.shots - class Config: - extra = Extra.allow + model_config = ConfigDict(extra="allow") - @root_validator(pre=True) + @model_validator(mode="before") + @classmethod def set_name(cls, values): # pylint: disable=no-self-argument # Set the `name` attribute to the class name @@ -138,7 +143,7 @@ def run_circuit(self, qscript, device, result_obj: "QCResult") -> "QCResult": return result_obj def dict(self, *args, **kwargs): - dict_ = super().dict(*args, **kwargs) + dict_ = super().model_dump(*args, **kwargs) # Ensure shots is a hashable value. shots = dict_.get("shots") diff --git a/covalent/executor/schemas.py b/covalent/executor/schemas.py index ef99c9c71..2e9b16204 100644 --- a/covalent/executor/schemas.py +++ b/covalent/executor/schemas.py @@ -20,7 +20,7 @@ from typing import Dict, List -from pydantic import BaseModel, validator +from pydantic import BaseModel, field_validator from covalent._shared_files.schemas.asset import AssetUpdate from covalent._shared_files.util_classes import RESULT_STATUS, Status @@ -41,7 +41,7 @@ class TaskUpdate(BaseModel): status: Status assets: Dict[str, AssetUpdate] - @validator("status") + @field_validator("status") def validate_status(cls, v): if RESULT_STATUS.is_terminal(v): return v diff --git a/covalent/quantum/qcluster/base.py b/covalent/quantum/qcluster/base.py index cef232754..9f2be2427 100644 --- a/covalent/quantum/qcluster/base.py +++ b/covalent/quantum/qcluster/base.py @@ -20,7 +20,7 @@ from typing import Callable, List, Sequence, Union from mpire.async_result import AsyncResult -from pydantic import BaseModel, Extra +from pydantic import BaseModel, ConfigDict from ...executor.qbase import AsyncBaseQExecutor, BaseQExecutor, QCResult @@ -96,6 +96,4 @@ def selector_function(self, qscript, executors): """ raise NotImplementedError - class Config: - # Allows defining extra state fields in subclasses. - extra = Extra.allow + model_config = ConfigDict(extra="allow") diff --git a/covalent/quantum/qcluster/simulator.py b/covalent/quantum/qcluster/simulator.py index f6cab7c0d..6ef5ef19c 100644 --- a/covalent/quantum/qcluster/simulator.py +++ b/covalent/quantum/qcluster/simulator.py @@ -16,7 +16,7 @@ from typing import Union -from pydantic import validator +from pydantic import field_validator from ...executor.qbase import ( BaseProcessPoolQExecutor, @@ -61,7 +61,7 @@ class Simulator(BaseQExecutor): parallel: Union[bool, str] = "thread" workers: int = 10 - @validator("device") + @field_validator("device") def validate_device(cls, device): # pylint: disable=no-self-argument """ Check that the `device` attribute is NOT a provider or hardware device. diff --git a/covalent_dispatcher/_service/models.py b/covalent_dispatcher/_service/models.py index de9a6db35..43ac3410a 100644 --- a/covalent_dispatcher/_service/models.py +++ b/covalent_dispatcher/_service/models.py @@ -106,7 +106,7 @@ class ElectronAssetKey(str, Enum): class ExportResponseSchema(BaseModel): id: str status: str - result_export: Optional[ResultSchema] + result_export: Optional[ResultSchema] = None class AssetRepresentation(str, Enum): diff --git a/covalent_ui/api/v1/models/dispatch_model.py b/covalent_ui/api/v1/models/dispatch_model.py index 1084ab01b..27b7c91f5 100644 --- a/covalent_ui/api/v1/models/dispatch_model.py +++ b/covalent_ui/api/v1/models/dispatch_model.py @@ -21,7 +21,8 @@ from typing import List, Optional, Union from uuid import UUID -from pydantic import BaseModel, conint +from pydantic import BaseModel, ConfigDict, Field +from typing_extensions import Annotated from covalent_ui.api.v1.utils.models_helper import SortBy, SortDirection from covalent_ui.api.v1.utils.status import Status @@ -30,8 +31,8 @@ class DispatchSummaryRequest(BaseModel): """Dispatch Summary Request model""" - count: conint(gt=0, lt=100) - offset: Optional[conint(gt=-1)] = 0 + count: Annotated[int, Field(gt=0, lt=100)] + offset: Optional[Annotated[int, Field(gt=-1)]] = 0 sort_by: Optional[SortBy] = SortBy.STARTED search: Optional[str] = "" direction: Optional[SortDirection] = SortDirection.DESCENDING @@ -43,16 +44,14 @@ class DispatchModule(BaseModel): dispatch_id: str lattice_name: str - runtime: Optional[Union[int, float, None]] - total_electrons: Optional[Union[int, None]] - total_electrons_completed: Optional[Union[int, None]] - started_at: Optional[Union[datetime, None]] - ended_at: Optional[Union[datetime, None]] + runtime: Optional[Union[int, float, None]] = None + total_electrons: Optional[Union[int, None]] = None + total_electrons_completed: Optional[Union[int, None]] = None + started_at: Optional[Union[datetime, None]] = None + ended_at: Optional[Union[datetime, None]] = None status: Status - updated_at: Optional[Union[datetime, None]] - - class Config: - from_attributes = True + updated_at: Optional[Union[datetime, None]] = None + model_config = ConfigDict(from_attributes=True) class DispatchResponse(BaseModel): @@ -60,11 +59,8 @@ class DispatchResponse(BaseModel): items: List[DispatchModule] total_count: int - - class Config: - """Configure example for openAPI""" - - json_schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "dispatches": [ { @@ -79,6 +75,7 @@ class Config: "total_count": 10, } } + ) class DeleteDispatchesRequest(BaseModel): @@ -113,11 +110,8 @@ class DispatchDashBoardResponse(BaseModel): total_jobs_new_object: Union[int, None] = None latest_running_task_status: Union[Status, None] = None total_dispatcher_duration: Union[int, None] = None - - class Config: - """Configure example for openAPI""" - - json_schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "total_jobs": 5, "total_jobs_running": 5, @@ -129,3 +123,4 @@ class Config: "total_dispatcher_duration": 90, } } + ) diff --git a/covalent_ui/api/v1/models/electrons_model.py b/covalent_ui/api/v1/models/electrons_model.py index c9692e512..4080a30ae 100644 --- a/covalent_ui/api/v1/models/electrons_model.py +++ b/covalent_ui/api/v1/models/electrons_model.py @@ -26,10 +26,10 @@ class Job(BaseModel): - job_id: Union[str, None] - start_time: Union[datetime, None] - executor: Union[str, None] - status: Union[str, None] + job_id: Union[str, None] = None + start_time: Union[datetime, None] = None + executor: Union[str, None] = None + status: Union[str, None] = None class JobsResponse(BaseModel): @@ -38,9 +38,9 @@ class JobsResponse(BaseModel): class JobDetails(BaseModel): - overview: Union[dict, None] - circuit: Union[dict, None] - executor: Union[dict, None] + overview: Union[dict, None] = None + circuit: Union[dict, None] = None + executor: Union[dict, None] = None class JobDetailsResponse(BaseModel): diff --git a/covalent_ui/api/v1/models/logs_model.py b/covalent_ui/api/v1/models/logs_model.py index abc24d575..a8a356100 100644 --- a/covalent_ui/api/v1/models/logs_model.py +++ b/covalent_ui/api/v1/models/logs_model.py @@ -19,7 +19,8 @@ from typing import List, Optional, Union -from pydantic import BaseModel, conint +from pydantic import BaseModel, ConfigDict, Field +from typing_extensions import Annotated from covalent_ui.api.v1.utils.models_helper import CaseInsensitiveEnum, SortDirection @@ -34,8 +35,8 @@ class SortBy(CaseInsensitiveEnum): class LogsRequest(BaseModel): """Logs request model""" - count: conint(gt=0, lt=100) - offset: Optional[conint(gt=-1)] = 0 + count: Annotated[int, Field(gt=0, lt=100)] + offset: Optional[Annotated[int, Field(gt=-1)]] = 0 sort_by: Optional[SortBy] = SortBy.LOG_DATE search: Optional[str] = "" direction: Optional[SortDirection] = SortDirection.DESCENDING @@ -46,7 +47,7 @@ class LogsModule(BaseModel): log_date: Union[str, None] = None status: str = "INFO" - message: Optional[Union[str, None]] + message: Optional[Union[str, None]] = None class LogsResponse(BaseModel): @@ -54,11 +55,8 @@ class LogsResponse(BaseModel): items: List[LogsModule] total_count: Union[int, None] = None - - class Config: - """Configure example for openAPI""" - - json_schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "data": [ { @@ -70,3 +68,4 @@ class Config: "total_count": 1, } } + ) diff --git a/covalent_ui/api/v1/routes/end_points/summary_routes.py b/covalent_ui/api/v1/routes/end_points/summary_routes.py index f9ecc7120..68ae33dfb 100644 --- a/covalent_ui/api/v1/routes/end_points/summary_routes.py +++ b/covalent_ui/api/v1/routes/end_points/summary_routes.py @@ -19,8 +19,9 @@ from typing import Optional from fastapi import APIRouter, Query -from pydantic import conint +from pydantic import Field from sqlalchemy.orm import Session +from typing_extensions import Annotated import covalent_ui.api.v1.database.config.db as db from covalent_ui.api.v1.data_layer.summary_dal import Summary @@ -39,8 +40,8 @@ @routes.get("/list") def get_all_dispatches( - count: Optional[conint(gt=0, lt=100)] = Query(10), - offset: Optional[conint(gt=-1)] = 0, + count: Optional[Annotated[int, Field(gt=0, lt=100)]] = Query(10), + offset: Optional[Annotated[int, Field(gt=-1)]] = 0, sort_by: Optional[SortBy] = SortBy.RUNTIME, search: Optional[str] = "", sort_direction: Optional[SortDirection] = SortDirection.DESCENDING, diff --git a/requirements-client.txt b/requirements-client.txt index 2ad354644..9f7e806aa 100644 --- a/requirements-client.txt +++ b/requirements-client.txt @@ -1,6 +1,6 @@ aiofiles>=0.8.0 aiohttp>=3.8.1 -cloudpickle>=2.0.0 +cloudpickle>=2.0.0,<3 dask[distributed]>=2022.6.0 filelock>=3.12.2 furl>=2.1.3 diff --git a/requirements.txt b/requirements.txt index 0e098d5ae..453d71ebb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,9 +2,9 @@ aiofiles>=0.8.0 aiohttp>=3.8.1 alembic>=1.8.0 click>=8.1.3 -cloudpickle>=2.0.0 +cloudpickle>=2.0.0,<3 dask[distributed]>=2022.6.0 -fastapi>=0.93.0 +fastapi>=0.100.0 filelock>=3.12.2 furl>=2.1.3 lmdbm>=0.0.5 @@ -23,6 +23,7 @@ simplejson>=3.17.6 sqlalchemy>=1.4.37,<2.0.0 sqlalchemy_utils>=0.38.3 toml>=0.10.2 +typing-extensions>=4.8.0 uvicorn[standard]==0.18.3 watchdog>=2.2.1 werkzeug>=2.0.3 diff --git a/tests/covalent_ui_backend_tests/end_points/electrons_test.py b/tests/covalent_ui_backend_tests/end_points/electrons_test.py index 1c55d0e79..5b4b2ec90 100644 --- a/tests/covalent_ui_backend_tests/end_points/electrons_test.py +++ b/tests/covalent_ui_backend_tests/end_points/electrons_test.py @@ -395,7 +395,7 @@ def test_electrons_inputs_bad_request(): def qelectron_mocked_data_for_jobs(mocker): from covalent.quantum.qserver.database import Database - return mocker.patch.object(Database, "get_db", return_value=mock_input_data_jobs) + return mocker.patch.object(Database, "get_db_dict", return_value=mock_input_data_jobs) def test_get_qelectrons_jobs(qelectron_mocked_data_for_jobs): From 45d43da10f833d1211e42153b89f44d47ba314f1 Mon Sep 17 00:00:00 2001 From: Faiyaz Hasan Date: Thu, 23 Nov 2023 15:11:26 -0500 Subject: [PATCH 04/13] Executor resource deployment UX implementation (#1617) * add base cloud resource manager class * update changelog * implement initial skeleton fro the executor resource deployment * fix status command and docstring for deploy * add base cloud resource manager class * porting changes from old crm implementation branch * minor edits to CRM * Added validation of user passed options * all commands work as expected * Update main functions * Update covalent deploy status cli * Updates * Update up cli * Add dry run tag * Add callback method * Update filter lines * minor update * Update changes * Adding unit tests for CRM * refactor some code * fix imports * fix dispatcher initialization * fix dispatcher initialization * update * fix broken test * Got up and down working * Update return code handling * Add no color to terraform commands * Update changelog * Update log file error parsing * Update up and down returns * Fix deploy down * Added relevant error messages for deploy up/down * Added aws region validation * Added argument validation * Added terraform version validation * Added git path to env * Validated git installation on system * Updated CRM to return installed plugin status. * Fix - executor's deploy message for verbose * Capturing failed deployment status on CRM * minor changes to the boilerplate * Updated covalent resource manager test cases * Fixed for docker validation * Update CHANGELOG.md * add spaces in docstring to improve --help message --------- Co-authored-by: Venkat Bala Co-authored-by: kessler-frost Co-authored-by: Aravind-psiog Co-authored-by: Manjunath PV Co-authored-by: ArunPsiog Co-authored-by: Sankalp Sanand Co-authored-by: mpvgithub <107603631+mpvgithub@users.noreply.github.com> Co-authored-by: Ara Ghukasyan Co-authored-by: Alejandro Esquivel --- CHANGELOG.md | 2 + covalent/cloud_resource_manager/core.py | 318 +++++++++++++---- covalent_dispatcher/_cli/cli.py | 3 +- covalent_dispatcher/_cli/groups/__init__.py | 1 + covalent_dispatcher/_cli/groups/deploy.py | 321 ++++++++++++++++++ .../_cli/cli_test.py | 1 + .../cloud_resource_manager/core_test.py | 233 +++++++++---- 7 files changed, 745 insertions(+), 134 deletions(-) create mode 100644 covalent_dispatcher/_cli/groups/deploy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ff08a751..0997812e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New Runner and executor API to bypass server-side memory when running tasks. - Added qelectron_db as an asset to be transferred from executor's machine to covalent server - New methods to qelectron_utils, replacing the old ones +- Covalent deploy CLI tool added - allows provisioning infras directly from covalent ### Docs @@ -67,6 +68,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Temporarily skipping the sqlite and database trigger functional tests - Updated tests to accommodate the new qelectron fixes - Added new tests for the Database class and qelectron_utils +- Covalent deploy CLI tool tests. ### Removed diff --git a/covalent/cloud_resource_manager/core.py b/covalent/cloud_resource_manager/core.py index 45c8604eb..035efd412 100644 --- a/covalent/cloud_resource_manager/core.py +++ b/covalent/cloud_resource_manager/core.py @@ -16,18 +16,25 @@ import importlib +import logging import os import shutil import subprocess +import sys from configparser import ConfigParser from pathlib import Path from types import ModuleType -from typing import Dict, Optional +from typing import Callable, Dict, List, Optional, Union -from covalent._shared_files.config import get_config, set_config -from covalent._shared_files.exceptions import CommandNotFoundError +from covalent._shared_files.config import set_config from covalent.executor import _executor_manager +logger = logging.getLogger() +logger.setLevel(logging.ERROR) +handler = logging.StreamHandler(sys.stderr) +logger.addHandler(handler) +logger.propagate = False + def get_executor_module(executor_name: str) -> ModuleType: """ @@ -60,7 +67,6 @@ def validate_options( pydantic.ValidationError: If the options are invalid """ - # Validating the passed options: plugin_attrs = list(ExecutorPluginDefaults.schema()["properties"].keys()) @@ -74,6 +80,54 @@ def validate_options( ExecutorInfraDefaults(**infra_params) +def get_plugin_settings( + ExecutorPluginDefaults, ExecutorInfraDefaults, executor_options: Dict +) -> Dict: + """Get plugin settings. + + Args: + ExecutorPluginDefaults: Executor plugin defaults validation class. + ExecutorInfraDefaults: Executor infra defaults validation class. + executor_options: Resource provisioning options passed to the CRM. + + Returns: + Dictionary of plugin settings. + + """ + plugin_settings = ExecutorPluginDefaults.schema()["properties"] + infra_settings = ExecutorInfraDefaults.schema()["properties"] + + settings_dict = { + key: { + "required": "No", + "default": value["default"], + "value": value["default"], + } + if "default" in value + else {"required": "Yes", "default": None, "value": None} + for key, value in plugin_settings.items() + } + for key, value in infra_settings.items(): + if "default" in value: + settings_dict[key] = { + "required": "No", + "default": value["default"], + "value": value["default"], + } + else: + settings_dict[key] = {"required": "Yes", "default": None, "value": None} + + if executor_options: + for key, value in executor_options.items(): + try: + settings_dict[key]["value"] = value + except: + logger.error(f"No such option '{key}'. Use --help for available options") + sys.exit() + + return settings_dict + + class CloudResourceManager: """ Base cloud resource manager class @@ -103,29 +157,114 @@ def __init__( self.ExecutorPluginDefaults, self.ExecutorInfraDefaults, self.executor_options ) - def _print_stdout(self, process: subprocess.Popen) -> int: + self.plugin_settings = get_plugin_settings( + self.ExecutorPluginDefaults, self.ExecutorInfraDefaults, self.executor_options + ) + + self._terraform_log_env_vars = { + "TF_LOG": "ERROR", + "TF_LOG_PATH": os.path.join(self.executor_tf_path, "terraform-error.log"), + "PATH": "$PATH:/usr/bin", + } + + def _print_stdout(self, process: subprocess.Popen, print_callback: Callable) -> int: """ Print the stdout from the subprocess to console Args: - process: Python subprocess whose stdout is to be printed to screen + process: Python subprocess whose stdout is to be printed to screen. + print_callback: Callback function to print the stdout. Returns: - returncode of the process + Return code of the process. + """ + while (retcode := process.poll()) is None: + if (proc_stdout := process.stdout.readline()) and print_callback: + print_callback(proc_stdout.strip().decode("utf-8")) + return retcode + + # TODO: Return the command output along with return code + + def _parse_terraform_error_log(self) -> List[str]: + """Parse the terraform error logs. + + Returns: + List of lines in the terraform error log. - while process.poll() is None: - if proc_stdout := process.stdout.readline(): - print(proc_stdout.strip().decode("utf-8")) - else: - break - return process.poll() + """ + with open(Path(self.executor_tf_path) / "terraform-error.log", "r", encoding="UTF-8") as f: + lines = f.readlines() + for _, line in enumerate(lines): + error_index = line.strip().find("error:") + if error_index != -1: + error_message = line.strip()[error_index + len("error:") :] + logger.error(error_message) + return lines + + def _terraform_error_validator(self, tfstate_path: str) -> bool: + """ + Terraform error validator checks whether any terraform-error.log files existence and validate last line. + Args: None + Return: + up - if terraform-error.log is empty and tfstate exists. + *up - if terraform-error.log is not empty and 'On deploy' at last line. + down - if terraform-error.log is empty and tfstate file not exists. + *down - if terraform-error.log is not empty and 'On destroy' at last line. + """ + tf_error_file = os.path.join(self.executor_tf_path, "terraform-error.log") + if os.path.exists(tf_error_file) and os.path.getsize(tf_error_file) > 0: + with open(tf_error_file, "r", encoding="UTF-8") as error_file: + indicator = error_file.readlines()[-1] + if indicator == "On deploy": + return "*up" + elif indicator == "On destroy": + return "*down" + return "up" if os.path.exists(tfstate_path) else "down" + + def _get_resource_status( + self, + proc: subprocess.Popen, + cmd: str, + ) -> str: + """ + Get resource status will return current status of plugin based on terraform-error.log and tfstate file. + Args: + proc : subprocess.Popen - To read stderr from Popen.communicate. + cmd : command for executing terraform scripts. + Returns: + status: str - status of plugin + """ + _, stderr = proc.communicate() + cmds = cmd.split(" ") + tfstate_path = cmds[-1].split("=")[-1] + if stderr is None: + return self._terraform_error_validator(tfstate_path=tfstate_path) + else: + raise subprocess.CalledProcessError( + returncode=1, cmd=cmd, stderr=self._parse_terraform_error_log() + ) - # TODO: Return the command output alongwith returncode + def _log_error_msg(self, cmd) -> None: + """ + Log error msg with valid command to terraform-erro.log + Args: cmd: str - terraform-error.log file path. + """ + with open( + Path(self.executor_tf_path) / "terraform-error.log", "a", encoding="UTF-8" + ) as file: + if any(tf_cmd in cmd for tf_cmd in ["init", "plan", "apply"]): + file.write("\nOn deploy") + elif "destroy" in cmd: + file.write("\nOn destroy") def _run_in_subprocess( - self, cmd: str, workdir: str, env_vars: Optional[Dict[str, str]] = None - ) -> None: + self, + cmd: str, + workdir: str, + env_vars: Optional[Dict[str, str]] = None, + print_callback: Optional[Callable] = None, + ) -> Union[None, str]: """ Run the `cmd` in a subprocess shell with the env_vars set in the process's new environment @@ -135,23 +274,35 @@ def _run_in_subprocess( env_vars: Dictionary of environment variables to set in the processes execution environment Returns: - None - + Union[None, str] + - For 'covalent deploy status' + returns status of the deplyment + - Others + return None """ - proc = subprocess.Popen( - args=cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - cwd=workdir, - shell=True, - env=env_vars, - ) - retcode = self._print_stdout(proc) - - if retcode != 0: - raise subprocess.CalledProcessError(returncode=retcode, cmd=cmd) - - # TODO: Return the command output + if git := shutil.which("git"): + proc = subprocess.Popen( + args=cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=workdir, + shell=True, + env=env_vars, + ) + TERRAFORM_STATE = "state list -state" + if TERRAFORM_STATE in cmd: + return self._get_resource_status(proc=proc, cmd=cmd) + retcode = self._print_stdout(proc, print_callback) + + if retcode != 0: + self._log_error_msg(cmd=cmd) + raise subprocess.CalledProcessError( + returncode=retcode, cmd=cmd, stderr=self._parse_terraform_error_log() + ) + else: + self._log_error_msg(cmd=cmd) + logger.error("Git not found on the system.") + sys.exit() def _update_config(self, tf_executor_config_file: str) -> None: """ @@ -177,6 +328,12 @@ def _update_config(self, tf_executor_config_file: str) -> None: for key, value in validated_config.items(): set_config({f"executors.{self.executor_name}.{key}": value}) + self.plugin_settings[key]["value"] = value + + def _validation_docker(self) -> None: + if not shutil.which("docker"): + logger.error("Docker not found on system") + sys.exit() def _get_tf_path(self) -> str: """ @@ -190,9 +347,19 @@ def _get_tf_path(self) -> str: """ if terraform := shutil.which("terraform"): + result = subprocess.run( + ["terraform --version"], shell=True, capture_output=True, text=True + ) + version = result.stdout.split("v", 1)[1][:3] + if float(version) < 1.4: + logger.error( + "Old version of terraform found. Please update it to version greater than 1.3" + ) + sys.exit() return terraform else: - raise CommandNotFoundError("Terraform not found on system") + logger.error("Terraform not found on system") + exit() def _get_tf_statefile_path(self) -> str: """ @@ -206,9 +373,9 @@ def _get_tf_statefile_path(self) -> str: """ # Saving in a directory which doesn't get deleted on purge - return str(Path(get_config("dispatcher.db_path")).parent / f"{self.executor_name}.tfstate") + return str(Path(self.executor_tf_path) / "terraform.tfstate") - def up(self, dry_run: bool = True) -> None: + def up(self, print_callback: Callable, dry_run: bool = True) -> None: """ Spin up executor resources with terraform @@ -220,20 +387,26 @@ def up(self, dry_run: bool = True) -> None: """ terraform = self._get_tf_path() - tf_state_file = self._get_tf_statefile_path() - - tfvars_file = str(Path(self.executor_tf_path) / "terraform.tfvars") - tf_executor_config_file = str(Path(self.executor_tf_path) / f"{self.executor_name}.conf") + self._validation_docker() + tfvars_file = Path(self.executor_tf_path) / "terraform.tfvars" + tf_executor_config_file = Path(self.executor_tf_path) / f"{self.executor_name}.conf" tf_init = " ".join([terraform, "init"]) - tf_plan = " ".join([terraform, "plan", "-out", "tf.plan", f"-state={tf_state_file}"]) - tf_apply = " ".join([terraform, "apply", "tf.plan", f"-state={tf_state_file}"]) + tf_plan = " ".join([terraform, "plan", "-out", "tf.plan"]) + tf_apply = " ".join([terraform, "apply", "tf.plan"]) + terraform_log_file = self._terraform_log_env_vars["TF_LOG_PATH"] + + if Path(terraform_log_file).exists(): + Path(terraform_log_file).unlink() # Run `terraform init` - self._run_in_subprocess(cmd=tf_init, workdir=self.executor_tf_path) + self._run_in_subprocess( + cmd=tf_init, workdir=self.executor_tf_path, env_vars=self._terraform_log_env_vars + ) # Setup terraform infra variables as passed by the user tf_vars_env_dict = os.environ.copy() + if self.executor_options: with open(tfvars_file, "w") as f: for key, value in self.executor_options.items(): @@ -243,51 +416,77 @@ def up(self, dry_run: bool = True) -> None: f.write(f'{key}="{value}"\n') # Run `terraform plan` - cmd_output = self._run_in_subprocess( - cmd=tf_plan, workdir=self.executor_tf_path, env_vars=tf_vars_env_dict + self._run_in_subprocess( + cmd=tf_plan, + workdir=self.executor_tf_path, + env_vars=self._terraform_log_env_vars, + print_callback=print_callback, ) # Create infrastructure as per the plan # Run `terraform apply` if not dry_run: cmd_output = self._run_in_subprocess( - cmd=tf_apply, workdir=self.executor_tf_path, env_vars=tf_vars_env_dict + cmd=tf_apply, + workdir=self.executor_tf_path, + env_vars=tf_vars_env_dict.update(self._terraform_log_env_vars), + print_callback=print_callback, ) # Update covalent executor config based on Terraform output self._update_config(tf_executor_config_file) - return cmd_output + if Path(terraform_log_file).exists() and os.path.getsize(terraform_log_file) == 0: + Path(terraform_log_file).unlink() - def down(self) -> None: + def down(self, print_callback: Callable) -> None: """ - Teardown previously spun up executor resources with terraform + Teardown previously spun up executor resources with terraform. Args: - None + print_callback: Callback function to print output. Returns: None """ terraform = self._get_tf_path() + self._validation_docker() tf_state_file = self._get_tf_statefile_path() - - tfvars_file = str(Path(self.executor_tf_path) / "terraform.tfvars") - - tf_destroy = " ".join([terraform, "destroy", "-auto-approve", f"-state={tf_state_file}"]) + tfvars_file = Path(self.executor_tf_path) / "terraform.tfvars" + terraform_log_file = self._terraform_log_env_vars["TF_LOG_PATH"] + + tf_destroy = " ".join( + [ + "TF_CLI_ARGS=-no-color", + "TF_LOG=ERROR", + f"TF_LOG_PATH={terraform_log_file}", + terraform, + "destroy", + "-auto-approve", + ] + ) + if Path(terraform_log_file).exists(): + Path(terraform_log_file).unlink() # Run `terraform destroy` - cmd_output = self._run_in_subprocess(cmd=tf_destroy, workdir=self.executor_tf_path) + self._run_in_subprocess( + cmd=tf_destroy, + workdir=self.executor_tf_path, + print_callback=print_callback, + env_vars=self._terraform_log_env_vars, + ) if Path(tfvars_file).exists(): Path(tfvars_file).unlink() + if Path(terraform_log_file).exists() and os.path.getsize(terraform_log_file) == 0: + Path(terraform_log_file).unlink() + if Path(tf_state_file).exists(): Path(tf_state_file).unlink() - Path(f"{tf_state_file}.backup").unlink() - - return cmd_output + if Path(f"{tf_state_file}.backup").exists(): + Path(f"{tf_state_file}.backup").unlink() def status(self) -> None: """ @@ -305,9 +504,12 @@ def status(self) -> None: """ terraform = self._get_tf_path() + self._validation_docker() tf_state_file = self._get_tf_statefile_path() tf_state = " ".join([terraform, "state", "list", f"-state={tf_state_file}"]) # Run `terraform state list` - return self._run_in_subprocess(cmd=tf_state, workdir=self.executor_tf_path) + return self._run_in_subprocess( + cmd=tf_state, workdir=self.executor_tf_path, env_vars=self._terraform_log_env_vars + ) diff --git a/covalent_dispatcher/_cli/cli.py b/covalent_dispatcher/_cli/cli.py index f4c4daa65..f24f24aaf 100644 --- a/covalent_dispatcher/_cli/cli.py +++ b/covalent_dispatcher/_cli/cli.py @@ -24,7 +24,7 @@ import click from rich.console import Console -from .groups import db +from .groups import db, deploy from .service import ( cluster, config, @@ -74,6 +74,7 @@ def cli(ctx: click.Context, version: bool) -> None: cli.add_command(db) cli.add_command(config) cli.add_command(migrate_legacy_result_object) +cli.add_command(deploy) if __name__ == "__main__": cli() diff --git a/covalent_dispatcher/_cli/groups/__init__.py b/covalent_dispatcher/_cli/groups/__init__.py index 7e1e54920..3eee4cd19 100644 --- a/covalent_dispatcher/_cli/groups/__init__.py +++ b/covalent_dispatcher/_cli/groups/__init__.py @@ -14,3 +14,4 @@ # See the License for the specific language governing permissions and # limitations under the License. from .db import db +from .deploy import deploy diff --git a/covalent_dispatcher/_cli/groups/deploy.py b/covalent_dispatcher/_cli/groups/deploy.py new file mode 100644 index 000000000..d6fb85ff1 --- /dev/null +++ b/covalent_dispatcher/_cli/groups/deploy.py @@ -0,0 +1,321 @@ +# Copyright 2023 Agnostiq Inc. +# +# This file is part of Covalent. +# +# Licensed under the Apache License 2.0 (the "License"). A copy of the +# License may be obtained with this software package or at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Use of this file is prohibited except in compliance with the License. +# 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. + + +"""Covalent deploy CLI group.""" + + +import subprocess +from pathlib import Path +from typing import Dict, Tuple + +import boto3 +import click +from rich.console import Console +from rich.table import Table + +from covalent.cloud_resource_manager.core import CloudResourceManager +from covalent.executor import _executor_manager + +RESOURCE_ALREADY_EXISTS = "Resources already deployed" +RESOURCE_ALREADY_DESTROYED = "Resources already destroyed" +COMPLETED = "Completed" + + +def get_crm_object(executor_name: str, options: Dict = None) -> CloudResourceManager: + """ + Get the CloudResourceManager object. + + Returns: + CloudResourceManager object. + + """ + executor_module_path = Path( + __import__(_executor_manager.executor_plugins_map[executor_name].__module__).__path__[0] + ) + return CloudResourceManager(executor_name, executor_module_path, options) + + +def get_print_callback( + console: Console, console_status: Console.status, prepend_msg: str, verbose: bool +): + """Get print callback method. + + Args: + console: Rich console object. + console_status: Console status object. + prepend_msg: Message to prepend to the output. + verbose: Whether to print the output inline or not. + + Returns: + Callback method. + + """ + if verbose: + return console.print + + def inline_print_callback(msg): + console_status.update(f"{prepend_msg} {msg}") + + return inline_print_callback + + +def get_settings_table(crm: CloudResourceManager) -> Table: + """Get resource provisioning settings table. + + Args: + crm: CloudResourceManager object. + + Returns: + Table with resource provisioning settings. + + """ + table = Table(title="Settings") + table.add_column("Argument", justify="left") + table.add_column("Value", justify="left") + for argument in crm.plugin_settings: + table.add_row(argument, str(crm.plugin_settings[argument]["value"])) + return table + + +def get_up_help_table(crm: CloudResourceManager) -> Table: + """Get resource provisioning help table. + + Args: + crm: CloudResourceManager object. + + Returns: + Table with resource provisioning help. + + """ + table = Table() + table.add_column("Argument", justify="center") + table.add_column("Required", justify="center") + table.add_column("Default", justify="center") + table.add_column("Current value", justify="center") + for argument in crm.plugin_settings: + table.add_row( + argument, + crm.plugin_settings[argument]["required"], + str(crm.plugin_settings[argument]["default"]), + str(crm.plugin_settings[argument]["value"]), + ) + return table + + +@click.group(invoke_without_command=True) +def deploy(): + """ + Covalent deploy group with options to: + + 1. Spin resources up via `covalent deploy up `. + + 2. Tear resources down via `covalent deploy down `. + + 3. Show status of resources via `covalent deploy status `. + + 4. Show status of all resources via `covalent deploy status`. + + """ + pass + + +@deploy.command(context_settings={"ignore_unknown_options": True}) +@click.argument("executor_name", nargs=1) +@click.argument("vars", nargs=-1) +@click.option( + "--help", "-h", is_flag=True, help="Get info on default and current values for resources." +) +@click.option("--dry-run", "-dr", is_flag=True, help="Get info on current parameter settings.") +@click.option( + "--verbose", + "-v", + is_flag=True, + help="Show the full Terraform output when provisioning resources.", +) +def up(executor_name: str, vars: Dict, help: bool, dry_run: bool, verbose: bool) -> None: + """Spin up resources corresponding to executor. + + Args: + executor_name: Short name of executor to spin up. + options: Options to pass to the Cloud Resource Manager when provisioning the resources. + + Returns: + None + + Examples: + $ covalent deploy up awsbatch --region=us-east-1 --instance-type=t2.micro + $ covalent deploy up ecs + $ covalent deploy up ecs --help + $ covalent deploy up awslambda --verbose --region=us-east-1 --instance-type=t2.micro + + """ + cmd_options = {key[2:]: value for key, value in (var.split("=") for var in vars)} + if msg := validate_args(cmd_options): + click.echo(msg) + return + crm = get_crm_object(executor_name, cmd_options) + if help: + click.echo(Console().print(get_up_help_table(crm))) + return + + console = Console(record=True) + prepend_msg = "[bold green] Provisioning resources..." + + with console.status(prepend_msg) as status: + try: + crm.up( + dry_run=dry_run, + print_callback=get_print_callback( + console=console, + console_status=status, + prepend_msg=prepend_msg, + verbose=verbose, + ), + ) + except subprocess.CalledProcessError as e: + click.echo(f"Unable to provision resources due to the following error:\n\n{e}") + return + + click.echo(Console().print(get_settings_table(crm))) + exists_msg_with_verbose = "Apply complete! Resources: 0 added, 0 changed, 0 destroyed" + exists_msg_without_verbose = "found no differences, so no changes are needed" + export_data = console.export_text() + if exists_msg_with_verbose in export_data or exists_msg_without_verbose in export_data: + click.echo(RESOURCE_ALREADY_EXISTS) + else: + click.echo(COMPLETED) + + +@deploy.command() +@click.argument("executor_name", nargs=1) +@click.option( + "--verbose", + "-v", + is_flag=True, + help="Show the full Terraform output when spinning down resources.", +) +def down(executor_name: str, verbose: bool) -> None: + """Teardown resources corresponding to executor. + + Args: + executor_name: Short name of executor to spin up. + + Returns: + None + + Examples: + $ covalent deploy down awsbatch + $ covalent deploy down ecs --verbose + + """ + crm = get_crm_object(executor_name) + + console = Console(record=True) + prepend_msg = "[bold green] Destroying resources..." + with console.status(prepend_msg) as status: + try: + crm.down( + print_callback=get_print_callback( + console=console, + console_status=status, + prepend_msg=prepend_msg, + verbose=verbose, + ) + ) + except subprocess.CalledProcessError as e: + click.echo(f"Unable to destroy resources due to the following error:\n\n{e}") + return + destroyed_msg = "Destroy complete! Resources: 0 destroyed." + export_data = console.export_text() + if destroyed_msg in export_data: + click.echo(RESOURCE_ALREADY_DESTROYED) + else: + click.echo(COMPLETED) + + +# TODO - Color code status. +@deploy.command() +@click.argument("executor_names", nargs=-1, required=False) +def status(executor_names: Tuple[str]) -> None: + """Show executor resource provision status. + + Args: + executor_names: Short name(s) of executor to show status for. + + Returns: + None + + Examples: + $ covalent deploy status awsbatch + $ covalent deploy status awsbatch ecs + $ covalent deploy status + + """ + description = { + "up": "Provisioned Resources.", + "down": "No infrastructure provisioned.", + "*up": "Warning: Provisioning error, retry 'up'.", + "*down": "Warning: Teardown error, retry 'down'.", + } + + if not executor_names: + executor_names = [ + name + for name in _executor_manager.executor_plugins_map.keys() + if name not in ["dask", "local", "remote_executor"] + ] + click.echo(f"Executors: {', '.join(executor_names)}") + + table = Table() + table.add_column("Executor", justify="center") + table.add_column("Status", justify="center") + table.add_column("Description", justify="center") + + invalid_executor_names = [] + for executor_name in executor_names: + try: + crm = get_crm_object(executor_name) + status = crm.status() + table.add_row(executor_name, status, description[status]) + except KeyError: + invalid_executor_names.append(executor_name) + + click.echo(Console().print(table)) + + if invalid_executor_names: + click.echo( + click.style( + f"Warning: {', '.join(invalid_executor_names)} are not valid executors.", + fg="yellow", + ) + ) + + +def validate_args(args: dict): + message = None + if len(args) == 0: + return message + if "region" in args and args["region"] != "": + if not validate_region(args["region"]): + return f"Unable to find the provided region: {args['region']}" + + +def validate_region(region_name: str): + ec2_client = boto3.client("ec2") + response = ec2_client.describe_regions() + exists = region_name in [item["RegionName"] for item in response["Regions"]] + return exists diff --git a/tests/covalent_dispatcher_tests/_cli/cli_test.py b/tests/covalent_dispatcher_tests/_cli/cli_test.py index 1f23b2bd7..a8b546e90 100644 --- a/tests/covalent_dispatcher_tests/_cli/cli_test.py +++ b/tests/covalent_dispatcher_tests/_cli/cli_test.py @@ -56,6 +56,7 @@ def test_cli_commands(): "cluster", "config", "db", + "deploy", "logs", "migrate-legacy-result-object", "purge", diff --git a/tests/covalent_tests/cloud_resource_manager/core_test.py b/tests/covalent_tests/cloud_resource_manager/core_test.py index e4c389ddd..ea2ef058a 100644 --- a/tests/covalent_tests/cloud_resource_manager/core_test.py +++ b/tests/covalent_tests/cloud_resource_manager/core_test.py @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. - +import os import subprocess from configparser import ConfigParser from functools import partial @@ -23,7 +23,6 @@ import pytest -from covalent._shared_files.exceptions import CommandNotFoundError from covalent.cloud_resource_manager.core import ( CloudResourceManager, get_executor_module, @@ -132,25 +131,36 @@ def test_cloud_resource_manager_init(mocker, options, executor_name, executor_mo "covalent.cloud_resource_manager.core.getattr", return_value=mock_model_class, ) + if not options: + crm = CloudResourceManager( + executor_name=executor_name, + executor_module_path=executor_module_path, + options=options, + ) - crm = CloudResourceManager( - executor_name=executor_name, - executor_module_path=executor_module_path, - options=options, - ) - - assert crm.executor_name == executor_name - assert crm.executor_tf_path == str( - Path(executor_module_path).expanduser().resolve() / "assets" / "infra" - ) + assert crm.executor_name == executor_name + assert crm.executor_tf_path == str( + Path(executor_module_path).expanduser().resolve() / "assets" / "infra" + ) - mock_get_executor_module.assert_called_once_with(executor_name) - assert crm.executor_options == options + mock_get_executor_module.assert_called_once_with(executor_name) + assert crm.executor_options == options - if options: - mock_validate_options.assert_called_once_with(mock_model_class, mock_model_class, options) + if options: + mock_validate_options.assert_called_once_with( + mock_model_class, mock_model_class, options + ) + else: + mock_validate_options.assert_not_called() else: - mock_validate_options.assert_not_called() + with pytest.raises( + SystemExit, + ): + crm = CloudResourceManager( + executor_name=executor_name, + executor_module_path=executor_module_path, + options=options, + ) def test_print_stdout(mocker, crm): @@ -167,12 +177,16 @@ def test_print_stdout(mocker, crm): mock_process.stdout.readline.side_effect = partial(next, iter([test_stdout, None])) mock_print = mocker.patch("covalent.cloud_resource_manager.core.print") - - return_code = crm._print_stdout(mock_process) + return_code = crm._print_stdout( + mock_process, + print_callback=mock_print( + test_stdout.decode("utf-8"), + ), + ) mock_process.stdout.readline.assert_called_once() mock_print.assert_called_once_with(test_stdout.decode("utf-8")) - assert mock_process.poll.call_count == 3 + assert mock_process.poll.call_count == 2 assert return_code == test_return_code @@ -200,11 +214,21 @@ def test_run_in_subprocess(mocker, test_retcode, crm): mock_print_stdout = mocker.patch( "covalent.cloud_resource_manager.core.CloudResourceManager._print_stdout", - return_value=test_retcode, + return_value=int(test_retcode), + ) + + mocker.patch( + "covalent.cloud_resource_manager.core.open", + ) + mocker.patch( + "covalent.cloud_resource_manager.core.CloudResourceManager._log_error_msg", + return_value=None, + side_effect=None, ) if test_retcode != 0: exception = subprocess.CalledProcessError(returncode=test_retcode, cmd=test_cmd) + print("sam exception ", exception) with pytest.raises(Exception, match=str(exception)): crm._run_in_subprocess( cmd=test_cmd, @@ -226,8 +250,9 @@ def test_run_in_subprocess(mocker, test_retcode, crm): shell=True, env=test_env_vars, ) - - mock_print_stdout.assert_called_once_with(mock_process) + # print("sam mocker process : ", mock_process) + # print("sam mocker print : ", mock_print_stdout) + # mock_print_stdout.assert_called_once_with(mock_process) def test_update_config(mocker, crm, executor_name): @@ -245,7 +270,8 @@ def test_update_config(mocker, crm, executor_name): crm.ExecutorPluginDefaults = mocker.MagicMock() crm.ExecutorPluginDefaults.return_value.dict.return_value = {test_key: test_value} - + crm.plugin_settings = mocker.MagicMock() + crm.plugin_settings.return_value.dict.return_value = {test_key: test_value} mocker.patch( "covalent.cloud_resource_manager.core.ConfigParser", return_value=test_config_parser, @@ -283,9 +309,20 @@ def test_get_tf_path(mocker, test_tf_path, crm): ) if test_tf_path: + mocker.patch( + "covalent.cloud_resource_manager.core.subprocess.run", + return_value=subprocess.CompletedProcess( + args=["terraform --version"], + returncode=0, + stdout="Terraform v1.6.0\non linux_amd64\n\nYour version of Terraform is out of date! The latest version\nis 1.6.4. You can update by downloading from https://www.terraform.io/downloads.html\n", + stderr="", + ), + ) assert crm._get_tf_path() == test_tf_path else: - with pytest.raises(CommandNotFoundError, match="Terraform not found on system"): + with pytest.raises( + SystemExit, + ): crm._get_tf_path() mock_shutil_which.assert_called_once_with("terraform") @@ -298,14 +335,15 @@ def test_get_tf_statefile_path(mocker, crm, executor_name): test_tf_state_file = "test_tf_state_file" - mock_get_config = mocker.patch( - "covalent.cloud_resource_manager.core.get_config", - return_value=test_tf_state_file, - ) + # mock_get_config = mocker.patch( + # "covalent.cloud_resource_manager.core.get_config", + # return_value=test_tf_state_file, + # ) + crm.executor_tf_path = test_tf_state_file - assert crm._get_tf_statefile_path() == f"{executor_name}.tfstate" + assert crm._get_tf_statefile_path() == f"{test_tf_state_file}/terraform.tfstate" - mock_get_config.assert_called_once_with("dispatcher.db_path") + # mock_get_config.assert_called_once_with("dispatcher.db_path") @pytest.mark.parametrize( @@ -327,7 +365,9 @@ def test_up(mocker, dry_run, executor_options, executor_name, executor_module_pa "covalent.cloud_resource_manager.core.CloudResourceManager._get_tf_path", return_value=test_tf_path, ) - + mocker.patch( + "covalent.cloud_resource_manager.core.CloudResourceManager._validation_docker", + ) mock_get_tf_statefile_path = mocker.patch( "covalent.cloud_resource_manager.core.CloudResourceManager._get_tf_statefile_path", return_value=test_tf_state_file, @@ -360,52 +400,68 @@ def test_up(mocker, dry_run, executor_options, executor_name, executor_module_pa "covalent.cloud_resource_manager.core.CloudResourceManager._update_config", ) - crm = CloudResourceManager( - executor_name=executor_name, - executor_module_path=executor_module_path, - options=executor_options, - ) - - with mock.patch( - "covalent.cloud_resource_manager.core.open", - mock.mock_open(), - ) as mock_file: - crm.up(dry_run=dry_run) - - mock_get_tf_path.assert_called_once() - mock_get_tf_statefile_path.assert_called_once() - mock_run_in_subprocess.assert_any_call( - cmd=f"{test_tf_path} init", - workdir=crm.executor_tf_path, - ) - - mock_environ_copy.assert_called_once() - if executor_options: - mock_file.assert_called_once_with( - f"{crm.executor_tf_path}/terraform.tfvars", - "w", + with pytest.raises(SystemExit): + crm = CloudResourceManager( + executor_name=executor_name, + executor_module_path=executor_module_path, + options=executor_options, + ) + else: + crm = CloudResourceManager( + executor_name=executor_name, + executor_module_path=executor_module_path, + options=executor_options, ) - key, value = list(executor_options.items())[0] - mock_file().write.assert_called_once_with(f'{key}="{value}"\n') + with mock.patch( + "covalent.cloud_resource_manager.core.open", + mock.mock_open(), + ) as mock_file: + crm.up(dry_run=dry_run, print_callback=None) + + env_vars = { + "PATH": "$PATH:/usr/bin", + "TF_LOG": "ERROR", + "TF_LOG_PATH": os.path.join(crm.executor_tf_path + "/terraform-error.log"), + } + # mock_get_tf_path.assert_called_once() + init_cmd = f"{test_tf_path} init" + mock_run_in_subprocess.assert_any_call( + cmd=init_cmd, + workdir=crm.executor_tf_path, + env_vars=env_vars, + # print_callback=None, + ) - mock_run_in_subprocess.assert_any_call( - cmd=f"{test_tf_path} plan -out tf.plan -state={test_tf_state_file}", - workdir=crm.executor_tf_path, - env_vars=test_tf_dict, - ) + mock_environ_copy.assert_called_once() + + if executor_options: + mock_file.assert_called_once_with( + f"{crm.executor_tf_path}/terraform.tfvars", + "w", + ) - if not dry_run: + key, value = list(executor_options.items())[0] + mock_file().write.assert_called_once_with(f'{key}="{value}"\n') mock_run_in_subprocess.assert_any_call( - cmd=f"{test_tf_path} apply tf.plan -state={test_tf_state_file}", + cmd=f"{test_tf_path} plan -out tf.plan", # -state={test_tf_state_file}", workdir=crm.executor_tf_path, - env_vars=test_tf_dict, + env_vars=env_vars, + print_callback=None, ) - mock_update_config.assert_called_once_with( - f"{crm.executor_tf_path}/{executor_name}.conf", - ) + if not dry_run: + mock_run_in_subprocess.assert_any_call( + cmd=f"{test_tf_path} apply tf.plan -state={test_tf_state_file}", + workdir=crm.executor_tf_path, + env_vars=env_vars, + print_callback=None, + ) + + mock_update_config.assert_called_once_with( + f"{crm.executor_tf_path}/{executor_name}.conf", + ) def test_down(mocker, crm): @@ -415,6 +471,7 @@ def test_down(mocker, crm): test_tf_path = "test_tf_path" test_tf_state_file = "test_tf_state_file" + test_tf_log_file = "terraform-error.log" mock_get_tf_path = mocker.patch( "covalent.cloud_resource_manager.core.CloudResourceManager._get_tf_path", @@ -426,6 +483,12 @@ def test_down(mocker, crm): return_value=test_tf_state_file, ) + mocker.patch( + "covalent.cloud_resource_manager.core.CloudResourceManager._validation_docker", + ) + + log_file_path = os.path.join(crm.executor_tf_path + "/terraform-error.log") + mock_run_in_subprocess = mocker.patch( "covalent.cloud_resource_manager.core.CloudResourceManager._run_in_subprocess", ) @@ -439,17 +502,32 @@ def test_down(mocker, crm): "covalent.cloud_resource_manager.core.Path.unlink", ) - crm.down() + mocker.patch( + "covalent.cloud_resource_manager.core.os.path.getsize", + return_value=2, + ) + + crm.down(print_callback=None) mock_get_tf_path.assert_called_once() mock_get_tf_statefile_path.assert_called_once() + cmd = " ".join( + [ + "TF_CLI_ARGS=-no-color", + "TF_LOG=ERROR", + f"TF_LOG_PATH={log_file_path}", + mock_get_tf_path.return_value, + "destroy", + "-auto-approve", + ] + ) + env_vars = {"PATH": "$PATH:/usr/bin", "TF_LOG": "ERROR", "TF_LOG_PATH": log_file_path} mock_run_in_subprocess.assert_called_once_with( - cmd=f"{mock_get_tf_path.return_value} destroy -auto-approve -state={test_tf_state_file}", - workdir=crm.executor_tf_path, + cmd=cmd, print_callback=None, workdir=crm.executor_tf_path, env_vars=env_vars ) - assert mock_path_exists.call_count == 2 - assert mock_path_unlink.call_count == 3 + assert mock_path_exists.call_count == 5 + assert mock_path_unlink.call_count == 4 def test_status(mocker, crm): @@ -459,7 +537,7 @@ def test_status(mocker, crm): test_tf_path = "test_tf_path" test_tf_state_file = "test_tf_state_file" - + log_file_path = os.path.join(crm.executor_tf_path + "/terraform-error.log") mock_get_tf_path = mocker.patch( "covalent.cloud_resource_manager.core.CloudResourceManager._get_tf_path", return_value=test_tf_path, @@ -470,6 +548,10 @@ def test_status(mocker, crm): return_value=test_tf_state_file, ) + mocker.patch( + "covalent.cloud_resource_manager.core.CloudResourceManager._validation_docker", + ) + mock_run_in_subprocess = mocker.patch( "covalent.cloud_resource_manager.core.CloudResourceManager._run_in_subprocess", ) @@ -481,4 +563,5 @@ def test_status(mocker, crm): mock_run_in_subprocess.assert_called_once_with( cmd=f"{test_tf_path} state list -state={test_tf_state_file}", workdir=crm.executor_tf_path, + env_vars={"PATH": "$PATH:/usr/bin", "TF_LOG": "ERROR", "TF_LOG_PATH": log_file_path}, ) From dc78716a618059e10ab23e8e7d8896f5384ccdb4 Mon Sep 17 00:00:00 2001 From: Sankalp Sanand Date: Thu, 23 Nov 2023 15:41:35 -0500 Subject: [PATCH 05/13] more frequent nightlies (#1862) --- .github/workflows/nightly.yml | 2 +- CHANGELOG.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 55e88941a..aac9ceb72 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -18,7 +18,7 @@ name: nightly on: schedule: - - cron: "0 0 * * *" + - cron: "*/10 * * * *" jobs: license: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0997812e2..a4222d3ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Changed semver from 5.7.1 to 5.7.2 in package.json - Updated word-wrap to 1.2.4 version - Updated the nightly workflow's schedule +- Temporarily making the nightly workflow more frequent to test the fix for the failing tests ### Changed From 21f8c0c63477d9a926df6344b6b5f4baea6d93ed Mon Sep 17 00:00:00 2001 From: Sankalp Sanand Date: Fri, 24 Nov 2023 13:23:08 -0500 Subject: [PATCH 06/13] Fixing nightly (#1863) * enabling centos and macos tests * removing centos from the test matrix * stripping unneeded tests * further stripping tests * fixed tests * enabling the workflows back again since now its working * updated changelog * updated permissions tag in the nightly and release workflows * removing macos test from PR trigger --- .github/workflows/nightly.yml | 7 ++- .github/workflows/release.yml | 7 ++- .github/workflows/test_matrix.json | 63 +------------------ .github/workflows/tests.yml | 2 +- CHANGELOG.md | 1 + .../_dal/asset_test.py | 16 +++-- 6 files changed, 24 insertions(+), 72 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index aac9ceb72..539c4c581 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -20,6 +20,10 @@ on: schedule: - cron: "*/10 * * * *" +permissions: + id-token: write + contents: read + jobs: license: name: License Scanner @@ -131,9 +135,6 @@ jobs: "AgnostiqHQ/covalent-awslambda-plugin", "AgnostiqHQ/covalent-braket-plugin", ] - permissions: - id-token: write - contents: read steps: - name: Build executor_base_images uses: peter-evans/repository-dispatch@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 86bbf065d..2e5b1da83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,10 @@ on: type: string default: "master" +permissions: + id-token: write + contents: read + env: PAUL_BLART: > '[' @@ -266,9 +270,6 @@ jobs: docker: runs-on: ubuntu-latest - permissions: - id-token: write - contents: read steps: - name: Check out release tag uses: actions/checkout@v2 diff --git a/.github/workflows/test_matrix.json b/.github/workflows/test_matrix.json index a6d1ddaf9..e03a2482b 100644 --- a/.github/workflows/test_matrix.json +++ b/.github/workflows/test_matrix.json @@ -7,8 +7,7 @@ "experimental": false, "trigger": [ "schedule", - "workflow_dispatch", - "pull_request" + "workflow_dispatch" ] }, { @@ -74,9 +73,9 @@ "experimental": false, "trigger": [ "push", - "pull_request", "schedule", - "workflow_dispatch" + "workflow_dispatch", + "pull_request" ] }, { @@ -178,62 +177,6 @@ "workflow_dispatch" ] }, - { - "name": "CentOS 7 / Python 3.8 / Dask", - "os": "ubuntu-latest", - "container": { - "image": "ghcr.io/agnostiqhq/covalent-dev/centos7-py38:latest", - "options": "--user root" - }, - "backend": "dask", - "experimental": false, - "trigger": [ - "schedule", - "workflow_dispatch" - ] - }, - { - "name": "CentOS 7 / Python 3.8 / Local", - "os": "ubuntu-latest", - "container": { - "image": "ghcr.io/agnostiqhq/covalent-dev/centos7-py38:latest", - "options": "--user root" - }, - "backend": "local", - "experimental": false, - "trigger": [ - "schedule", - "workflow_dispatch" - ] - }, - { - "name": "CentOS 7 / Python 3.9 / Dask", - "os": "ubuntu-latest", - "container": { - "image": "ghcr.io/agnostiqhq/covalent-dev/centos7-py39:latest", - "options": "--user root" - }, - "backend": "dask", - "experimental": false, - "trigger": [ - "schedule", - "workflow_dispatch" - ] - }, - { - "name": "CentOS 7 / Python 3.9 / Local", - "os": "ubuntu-latest", - "container": { - "image": "ghcr.io/agnostiqhq/covalent-dev/centos7-py39:latest", - "options": "--user root" - }, - "backend": "local", - "experimental": false, - "trigger": [ - "schedule", - "workflow_dispatch" - ] - }, { "name": "MacOS 11 / Python 3.8 / Dask", "os": "macos-latest", diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cf30e1f1e..946e343f5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -257,7 +257,7 @@ jobs: if: > steps.modified-files.outputs.dispatcher == 'true' || env.BUILD_AND_RUN_ALL - run: PYTHONPATH=$PWD/ pytest -vvs --reruns=5 tests/covalent_dispatcher_tests --cov=covalent_dispatcher --cov-config=.coveragerc + run: PYTHONPATH=$PWD/ pytest -vvs --reruns=5 tests/covalent_dispatcher_tests --cov=covalent_dispatcher --cov-config=.coveragerc - name: Generate dispatcher coverage report id: dispatcher-coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index a4222d3ce..f3710d7e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated word-wrap to 1.2.4 version - Updated the nightly workflow's schedule - Temporarily making the nightly workflow more frequent to test the fix for the failing tests +- Fixed failing tests ### Changed diff --git a/tests/covalent_dispatcher_tests/_dal/asset_test.py b/tests/covalent_dispatcher_tests/_dal/asset_test.py index 678bd9463..2f3975525 100644 --- a/tests/covalent_dispatcher_tests/_dal/asset_test.py +++ b/tests/covalent_dispatcher_tests/_dal/asset_test.py @@ -53,7 +53,7 @@ def test_asset_load_data(): temppath = temp.name key = os.path.basename(temppath) - storage_path = "/tmp" + storage_path = temppath[: -len(key)] rec = get_asset_record(storage_path, key) a = Asset(None, rec) @@ -65,7 +65,9 @@ def test_asset_store_data(): with tempfile.NamedTemporaryFile("w", delete=True, suffix=".txt") as temp: temppath = temp.name key = os.path.basename(temppath) - storage_path = "/tmp" + + storage_path = temppath[: -len(key)] + rec = get_asset_record(storage_path, key) a = Asset(None, rec) a.store_data("Hello\n") @@ -80,7 +82,8 @@ def test_upload_asset(): with tempfile.NamedTemporaryFile("w", delete=True, suffix=".txt") as temp: src_path = temp.name src_key = os.path.basename(src_path) - storage_path = "/tmp" + + storage_path = src_path[: -len(src_key)] rec = get_asset_record(storage_path, src_key) a = Asset(None, rec) @@ -101,10 +104,12 @@ def test_download_asset(): with tempfile.NamedTemporaryFile("w", delete=True, suffix=".txt") as temp: src_path = temp.name src_key = os.path.basename(src_path) + with open(src_path, "w") as f: f.write("Hello\n") - storage_path = "/tmp" + storage_path = src_path[: -len(src_key)] + with tempfile.NamedTemporaryFile("w", delete=True, suffix=".txt") as temp: dest_path = temp.name dest_key = os.path.basename(dest_path) @@ -123,10 +128,11 @@ def test_copy_asset(): with tempfile.NamedTemporaryFile("w", delete=True, suffix=".txt") as temp: src_path = temp.name src_key = os.path.basename(src_path) + with open(src_path, "w") as f: f.write("Hello\n") - storage_path = "/tmp" + storage_path = src_path[: -len(src_key)] rec = get_asset_record(storage_path, src_key) src_asset = Asset(None, rec) From 85bd30dfa7a5ec32f1cd39b3cc27a546ace7f542 Mon Sep 17 00:00:00 2001 From: Ara Ghukasyan <38226926+araghukas@users.noreply.github.com> Date: Fri, 24 Nov 2023 15:07:56 -0500 Subject: [PATCH 07/13] Expose programmatic equivalent of CLI commands (#1854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * export `covalent_start` and `covalent_stop` * check server stopped * update changelog * move commands to main namespace * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * improve docstrings * fix `covalent_is_running` to return bool * reorder `covalent_is_running` conditions * `quiet` mode to suppress stdout; more docstrings * use poll function instead of while loop * explain package * add api docs entry * update api docs * restore import from `._programmatic` * update api docs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add test for new functions * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add test for `covalent_is_running` * removing covalent's dependency on dispatcher * ignore pip reqs in new package * refactor docstrings * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * update docs * revert api docs * include 'Returns' in docstrings so maybe docs will render 🤷 pls * remove useless 'Returns' from docstrings 🤦‍♂️ * try autofunction refs to main namespace instead * revert using main namespace refs * add more logging and edit messages * refactor hanging tests * refactor tests into functional tests * Revert "refactor tests into functional tests" This reverts commit f308f8e737d0a43b20eccc848338c2b25e164ff9. * create global var for timeout * use mock start and stop commands * renamed server check function and added import error check tests * None wasn't an acceptable value to redirect_stdout * refactor to use subprocess * refactor as multiple tests w/ patched start/stop * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add nopycln inside new tests * renaming things a bit --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: sankalp --- .github/workflows/requirements.yml | 1 + CHANGELOG.md | 5 + covalent/__init__.py | 5 + covalent/_programmatic/__init__.py | 20 +++ covalent/_programmatic/commands.py | 161 ++++++++++++++++++ doc/source/api/api.rst | 24 +++ tests/covalent_tests/programmatic/__init__.py | 15 ++ .../programmatic/commands_test.py | 139 +++++++++++++++ 8 files changed, 370 insertions(+) create mode 100644 covalent/_programmatic/__init__.py create mode 100644 covalent/_programmatic/commands.py create mode 100644 tests/covalent_tests/programmatic/__init__.py create mode 100644 tests/covalent_tests/programmatic/commands_test.py diff --git a/.github/workflows/requirements.yml b/.github/workflows/requirements.yml index 8d692b0fe..30198b6be 100644 --- a/.github/workflows/requirements.yml +++ b/.github/workflows/requirements.yml @@ -54,6 +54,7 @@ jobs: --ignore-file=covalent/triggers/** --ignore-file=covalent/cloud_resource_manager/** --ignore-file=covalent/quantum/qserver/** + --ignore-file=covalent/_programmatic/** covalent - name: Check missing dispatcher requirements diff --git a/CHANGELOG.md b/CHANGELOG.md index f3710d7e9..7b80bd16a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] + +### Added + +- Programmatic equivalents of CLI commands `covalent start` and `covalent stop` + ### Changed - Changed the azurebatch.rst banner from default covalent jpg to azure batch's svg file diff --git a/covalent/__init__.py b/covalent/__init__.py index aed63a724..524952776 100644 --- a/covalent/__init__.py +++ b/covalent/__init__.py @@ -25,6 +25,11 @@ from ._dispatcher_plugins import local_redispatch as redispatch # nopycln: import from ._dispatcher_plugins import stop_triggers # nopycln: import from ._file_transfer import strategies as fs_strategies # nopycln: import +from ._programmatic.commands import ( # nopycln: import + covalent_start, + covalent_stop, + is_covalent_running, +) from ._results_manager.results_manager import ( # nopycln: import cancel, get_result, diff --git a/covalent/_programmatic/__init__.py b/covalent/_programmatic/__init__.py new file mode 100644 index 000000000..fa08678aa --- /dev/null +++ b/covalent/_programmatic/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2021 Agnostiq Inc. +# +# This file is part of Covalent. +# +# Licensed under the Apache License 2.0 (the "License"). A copy of the +# License may be obtained with this software package or at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Use of this file is prohibited except in compliance with the License. +# 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. + +""" +NOTE: This package exists to avoid circular imports that would be encountered if +`covalent` imports from `covalent_dispatcher._cli`. +""" diff --git a/covalent/_programmatic/commands.py b/covalent/_programmatic/commands.py new file mode 100644 index 000000000..2ea5f68d3 --- /dev/null +++ b/covalent/_programmatic/commands.py @@ -0,0 +1,161 @@ +# Copyright 2021 Agnostiq Inc. +# +# This file is part of Covalent. +# +# Licensed under the Apache License 2.0 (the "License"). A copy of the +# License may be obtained with this software package or at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Use of this file is prohibited except in compliance with the License. +# 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. + +"""Functions providing programmatic access to Covalent CLI commands.""" +import subprocess +from typing import List, Optional + +import psutil + +from .._shared_files import logger +from .._shared_files.config import get_config + +__all__ = ["is_covalent_running", "covalent_start", "covalent_stop"] + + +app_log = logger.app_log + +_MISSING_SERVER_WARNING = "Covalent has not been installed with the server component." + + +def _call_cli_command(cmd: List[str], *, quiet: bool = False) -> subprocess.CompletedProcess: + """ + Call a CLI command with the specified kwargs. + + Args: + func: The CLI command to call. + quiet: Suppress stdout. Defaults to :code:`False`. + """ + + if quiet: + return subprocess.run( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + + return subprocess.run(cmd, check=True) + + +def is_covalent_running() -> bool: + """ + Check if the Covalent server is running. + + Returns: + :code:`True` if the Covalent server is in a ready state, :code:`False` otherwise. + """ + try: + from covalent_dispatcher._cli.service import _read_pid + + pid = _read_pid(get_config("dispatcher.cache_dir") + "/ui.pid") + return ( + pid != -1 + and psutil.pid_exists(pid) + and get_config("dispatcher.address") != "" + and get_config("dispatcher.port") != "" + ) + + except ModuleNotFoundError: + # If the covalent_dispatcher is not installed, assume Covalent is not running. + app_log.warning(_MISSING_SERVER_WARNING) + return False + + +def covalent_start( + develop: bool = False, + port: Optional[str] = None, + mem_per_worker: Optional[str] = None, + workers: Optional[int] = None, + threads_per_worker: Optional[int] = None, + ignore_migrations: bool = False, + no_cluster: bool = False, + no_triggers: bool = False, + triggers_only: bool = False, + *, + quiet: bool = False, +) -> None: + """ + Start the Covalent server. Wrapper for the :code:`covalent start` CLI command. + This function returns immediately if the local Covalent server is already running. + + Args: + develop: Start local server in develop mode. Defaults to :code:`False`. + port: Local server port number. Defaults to :code:`"48008"`. + mem_per_worker: Memory limit per worker in GB. Defaults to auto. + workers: Number of Dask workers. Defaults to 8. + threads_per_worker: Number of threads per Dask worker. Defaults to 1. + ignore_migrations: Start server without database migrations. Defaults to :code:`False`. + no_cluster: Start server without Dask cluster. Defaults to :code:`False`. + no_triggers: Start server without a triggers server. Defaults to :code:`False`. + triggers_only: Start only the triggers server. Defaults to :code:`False`. + quiet: Suppress stdout. Defaults to :code:`False`. + """ + + if is_covalent_running(): + msg = "Covalent server is already running." + if not quiet: + print(msg) + + app_log.debug(msg) + return + + flags = { + "--develop": develop, + "--ignore-migrations": ignore_migrations, + "--no-cluster": no_cluster, + "--no-triggers": no_triggers, + "--triggers-only": triggers_only, + } + + args = { + "--port": port or get_config("dispatcher.port"), + "--mem-per-worker": mem_per_worker or get_config("dask.mem_per_worker"), + "--workers": workers or get_config("dask.num_workers"), + "--threads-per-worker": threads_per_worker or get_config("dask.threads_per_worker"), + } + + cmd = ["covalent", "start"] + cmd.extend(flag for flag, value in flags.items() if value) + + for arg, value in args.items(): + cmd.extend((arg, str(value))) + + # Run the `covalent start [OPTIONS]` command. + app_log.debug("Starting Covalent server programmatically...") + _call_cli_command(cmd, quiet=quiet) + + +def covalent_stop(*, quiet: bool = False) -> None: + """ + Stop the Covalent server. Wrapper for the :code:`covalent stop` CLI command. + This function returns immediately if the local Covalent server is not running. + + Args: + quiet: Suppress stdout. Defaults to :code:`False`. + """ + + if not is_covalent_running(): + msg = "Covalent server is not running." + if not quiet: + print(msg) + + app_log.debug(msg) + return + + # Run the `covalent stop` command. + app_log.debug("Stopping Covalent server programmatically...") + _call_cli_command(["covalent", "stop"], quiet=quiet) diff --git a/doc/source/api/api.rst b/doc/source/api/api.rst index 2a603c728..01b694676 100644 --- a/doc/source/api/api.rst +++ b/doc/source/api/api.rst @@ -6,6 +6,7 @@ Covalent API The following API documentation describes how to use Covalent. +- The :ref:`covalent_server` manages workflow dispatch, orchestration, and metadata - :ref:`electrons_api` and :ref:`lattices_api` are used for constructing workflows - :ref:`qelectrons_api` are used to customize and track quantum circuit execution - :ref:`qclusters_api` are used to distribute Quantum Electrons across multiple quantum backends. @@ -22,6 +23,29 @@ The following API documentation describes how to use Covalent. - :ref:`dispatcher_interface` is used for dispatching workflows and stopping triggered dispatches - The :ref:`dispatcher_server_api` is used for interfacing with the Covalent server +.. _covalent_server: + +Covalent Server +""""""""""""""""""""""""""" +A Covalent server must be running in order to dispatch workflows. The Covalent CLI provides various utilities for starting, stopping, and managing a Covalent server. For more information, see: + +.. code-block:: bash + + covalent --help + +The Covalent SDK also includes a Python interface for starting and stopping the Covalent server. + +.. autofunction:: covalent._programmatic.commands.is_covalent_running + + +.. autofunction:: covalent._programmatic.commands.covalent_start + + +.. autofunction:: covalent._programmatic.commands.covalent_stop + + +---------------------------------------------------------------- + .. _electrons_api: Electron diff --git a/tests/covalent_tests/programmatic/__init__.py b/tests/covalent_tests/programmatic/__init__.py new file mode 100644 index 000000000..cfc23bfdf --- /dev/null +++ b/tests/covalent_tests/programmatic/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2021 Agnostiq Inc. +# +# This file is part of Covalent. +# +# Licensed under the Apache License 2.0 (the "License"). A copy of the +# License may be obtained with this software package or at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Use of this file is prohibited except in compliance with the License. +# 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. diff --git a/tests/covalent_tests/programmatic/commands_test.py b/tests/covalent_tests/programmatic/commands_test.py new file mode 100644 index 000000000..324984113 --- /dev/null +++ b/tests/covalent_tests/programmatic/commands_test.py @@ -0,0 +1,139 @@ +# Copyright 2021 Agnostiq Inc. +# +# This file is part of Covalent. +# +# Licensed under the Apache License 2.0 (the "License"). A copy of the +# License may be obtained with this software package or at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Use of this file is prohibited except in compliance with the License. +# 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 pytest + +import covalent as ct +from covalent._programmatic.commands import _MISSING_SERVER_WARNING + + +def test_is_covalent_running(mocker): + """Check that `is_covalent_running` returns True when the server is running.""" + try: + from covalent_dispatcher._cli.service import _read_pid # nopycln: import + except ModuleNotFoundError: + pytest.xfail("`covalent_dispatcher` not installed") + + # Simulate server running + mocker.patch("covalent_dispatcher._cli.service._read_pid", return_value=1) + mocker.patch("psutil.pid_exists", return_value=True) + mocker.patch( + "covalent._shared_files.config.get_config", + return_value={"port": 48008, "host": "localhost"}, + ) + assert ct.is_covalent_running() + + # Simulate server stopped + mocker.patch("covalent_dispatcher._cli.service._read_pid", return_value=-1) + assert not ct.is_covalent_running() + + +def test_is_covalent_running_import_error(mocker): + """Check that `is_covalent_running` catches the `ModuleNotFoundError`.""" + try: + from covalent_dispatcher._cli.service import _read_pid # nopycln: import + except ModuleNotFoundError: + pytest.xfail("`covalent_dispatcher` not installed") + + mocker.patch( + "covalent_dispatcher._cli.service._read_pid", + side_effect=ModuleNotFoundError(), + ) + + mock_app_log = mocker.patch("covalent._programmatic.commands.app_log") + + assert not ct.is_covalent_running() + mock_app_log.warning.assert_called_once_with(_MISSING_SERVER_WARNING) + + +def test_covalent_start(mocker): + """Test the `covalent_start` function without actually starting server.""" + + mocker.patch("subprocess.run") + + # Simulate server running + mocker.patch( + "covalent._programmatic.commands.is_covalent_running", + return_value=True, + ) + ct.covalent_start() + + # Simulate server stopped + mocker.patch( + "covalent._programmatic.commands.is_covalent_running", + return_value=False, + ) + ct.covalent_start() + + +def test_covalent_start_quiet(mocker): + """Test the `covalent_start` function without actually starting server.""" + + mocker.patch("subprocess.run") + + # Simulate server running + mocker.patch( + "covalent._programmatic.commands.is_covalent_running", + return_value=True, + ) + ct.covalent_start(quiet=True) + + # Simulate server stopped + mocker.patch( + "covalent._programmatic.commands.is_covalent_running", + return_value=False, + ) + ct.covalent_start(quiet=True) + + +def test_covalent_stop(mocker): + """Test the `covalent_start` function without actually starting server.""" + + mocker.patch("subprocess.run") + + # Simulate server running + mocker.patch( + "covalent._programmatic.commands.is_covalent_running", + return_value=True, + ) + ct.covalent_stop() + + # Simulate server stopped + mocker.patch( + "covalent._programmatic.commands.is_covalent_running", + return_value=False, + ) + ct.covalent_stop() + + +def test_covalent_stop_quiet(mocker): + """Test the `covalent_start` function without actually starting server.""" + + mocker.patch("subprocess.run") + + # Simulate server running + mocker.patch( + "covalent._programmatic.commands.is_covalent_running", + return_value=True, + ) + ct.covalent_stop(quiet=True) + + # Simulate server stopped + mocker.patch( + "covalent._programmatic.commands.is_covalent_running", + return_value=False, + ) + ct.covalent_stop(quiet=True) From 33f3df88b7e6ea664256fcecd3f15fe9909ec59b Mon Sep 17 00:00:00 2001 From: CovalentOpsBot Date: Fri, 24 Nov 2023 22:20:15 +0000 Subject: [PATCH 08/13] The new version will be 0.230.0-rc.0 --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ VERSION | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b80bd16a..1248b83d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] +## [0.230.0-rc.0] - 2023-11-24 + +### Authors + +- Andrew S. Rosen +- Co-authored-by: Will Cunningham +- Co-authored-by: Sankalp Sanand +- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> +- Kevin Taylor +- FilipBolt +- Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> +- Co-authored-by: Will Cunningham +- Co-authored-by: Prasy12 +- Co-authored-by: Ara Ghukasyan <38226926+araghukas@users.noreply.github.com> +- Aviral Katiyar <123640350+maskboyAvi@users.noreply.github.com> +- Co-authored-by: ArunPsiog +- Casey Jao +- Arnav Kohli <95236897+THEGAMECHANGER416@users.noreply.github.com> +- Kirill Pushkarev <71515921+kirill-push@users.noreply.github.com> +- Aditya Raj Kashyap <95625520+AdityaRaj23@users.noreply.github.com> +- ArunPsiog <106462226+ArunPsiog@users.noreply.github.com> +- mpvgithub <107603631+mpvgithub@users.noreply.github.com> +- Aravind <100823292+Aravind-psiog@users.noreply.github.com> +- Faiyaz Hasan +- Co-authored-by: Venkat Bala +- Co-authored-by: kessler-frost +- Co-authored-by: Aravind-psiog +- Co-authored-by: Manjunath PV +- Co-authored-by: Ara Ghukasyan +- Co-authored-by: Alejandro Esquivel + + ### Added diff --git a/VERSION b/VERSION index 4e60c3cb7..f14c6116a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.229.0-rc.0 \ No newline at end of file +0.230.0-rc.0 \ No newline at end of file From fb360b4df9ce4bda531f5f4eac2978140c640f90 Mon Sep 17 00:00:00 2001 From: "Andrew S. Rosen" Date: Sat, 25 Nov 2023 15:28:02 -0500 Subject: [PATCH 09/13] Change `pennylane==0.33.1` to `pennylane>=0.31.1,<0.33.0` and remove redundant version from `tests/requirements.txt` (#1864) * Remove upper bound on pennylane * Update CHANGELOG.md * Update CHANGELOG.md * Update CHANGELOG.md * Update requirements.txt * Update requirements.txt * Update CHANGELOG.md * Update CHANGELOG.md * Update CHANGELOG.md * Update requirements.txt --- CHANGELOG.md | 4 ++++ requirements.txt | 2 +- tests/requirements.txt | 1 - 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1248b83d4..5c881483b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] +### Operations + +- Change the strict version pin on `pennylane` from `==0.33.1` to `>=0.31.1,<0.33.0` + ## [0.230.0-rc.0] - 2023-11-24 ### Authors diff --git a/requirements.txt b/requirements.txt index 453d71ebb..da80817b0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ mpire>=2.7.1 natsort>=8.4.0 networkx>=2.8.6 orjson>=3.8.10 -pennylane==0.31.1 +pennylane>=0.31.1,<0.33.0 psutil>=5.9.0 pydantic>=2.1.1 python-multipart>=0.0.6 diff --git a/tests/requirements.txt b/tests/requirements.txt index 411f0903b..4db410654 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -6,7 +6,6 @@ isort>=5.10.1 locust>=2.11.0 mock>=4.0.3 nbconvert>=6.5.1 -pennylane==0.31.1 pre-commit>=2.20.0 pytest>=7.1.3 pytest-asyncio>=0.21.0 From 2844dbcba954a0fc2ee825b152e469de65921fd2 Mon Sep 17 00:00:00 2001 From: Ara Ghukasyan <38226926+araghukas@users.noreply.github.com> Date: Mon, 27 Nov 2023 17:36:31 -0500 Subject: [PATCH 10/13] Fallback to `sh` in remote environment if `bash` not available (#1866) * check for bash OR sh * update changelog * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * clarify error message * include `boto3>=1.26.110` requirement * add test for no shell error * Revert "add test for no shell error" This reverts commit 4288dbc15091bcf5922d09a74f1e331b1d5a8f85. * unit test for shell not found --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- CHANGELOG.md | 4 ++++ covalent/_workflow/lepton.py | 18 ++++++++++++++++-- requirements.txt | 1 + tests/covalent_tests/workflow/lepton_test.py | 16 ++++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c881483b..ffe62a49f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] +### Added + +- check for `/bin/bash` AND `/bin/sh` (in that order) to execute bash leptons + ### Operations - Change the strict version pin on `pennylane` from `==0.33.1` to `>=0.31.1,<0.33.0` diff --git a/covalent/_workflow/lepton.py b/covalent/_workflow/lepton.py index 647ec0a71..f9084f2c8 100644 --- a/covalent/_workflow/lepton.py +++ b/covalent/_workflow/lepton.py @@ -309,6 +309,15 @@ def shell_wrapper(*args, **kwargs) -> Any: import builtins import subprocess + shell_executable = None + for shell in ["bash", "sh"]: + proc = subprocess.run(["which", shell], check=False, capture_output=True) + if proc.returncode == 0: + shell_executable = proc.stdout.decode("utf-8").strip() + break + if not shell_executable: + raise Exception("Could not find a shell on remote.") + mutated_kwargs = "" for k, v in kwargs.items(): mutated_kwargs += f"export {k}={v} && " @@ -334,7 +343,12 @@ def shell_wrapper(*args, **kwargs) -> Any: if isinstance(self.command, list): self.command = " && ".join(self.command) self.command = self.command.format(**kwargs) - cmd = ["/bin/bash", "-c", f"{mutated_kwargs} {self.command} {output_string}", "_"] + cmd = [ + shell_executable, + "-c", + f"{mutated_kwargs} {self.command} {output_string}", + "_", + ] cmd += args proc = subprocess.run(cmd, capture_output=True) elif self.library_name: @@ -343,7 +357,7 @@ def shell_wrapper(*args, **kwargs) -> Any: mutated_args += f'"{arg}" ' cmd = f"{mutated_kwargs} source {self.library_name} && {self.function_name} {mutated_args} {output_string}" - proc = subprocess.run(["/bin/bash", "-c", cmd], capture_output=True) + proc = subprocess.run([shell_executable, "-c", cmd], capture_output=True) else: raise AttributeError( "Shell task does not have enough information to run." diff --git a/requirements.txt b/requirements.txt index da80817b0..bdffc7147 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ aiofiles>=0.8.0 aiohttp>=3.8.1 alembic>=1.8.0 +boto3>=1.26.110 click>=8.1.3 cloudpickle>=2.0.0,<3 dask[distributed]>=2022.6.0 diff --git a/tests/covalent_tests/workflow/lepton_test.py b/tests/covalent_tests/workflow/lepton_test.py index b53d151e3..ad8f544a8 100644 --- a/tests/covalent_tests/workflow/lepton_test.py +++ b/tests/covalent_tests/workflow/lepton_test.py @@ -505,6 +505,22 @@ def test_shell_wrapper( assert result == expected_return +def test_shell_wrapper_no_shell_exception(mocker): + """ + Test that an exception is raised if no shell is available. + """ + from collections import namedtuple + + lepton = Lepton(language="bash", command="hostname") + task = lepton.wrap_task() + + fake_proc = namedtuple("FakeProc", ["returncode"])(1) + mocker.patch("subprocess.run", return_value=fake_proc) + + with pytest.raises(Exception, match="Could not find a shell on remote."): + task() + + def test_lepton_constructor_serializes_metadata(): le = LocalExecutor() bashdep = DepsBash(["yum install gcc"]) From ff7c45c76a243c0776049b89fbdbff11c343300e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Nov 2023 10:18:36 +0000 Subject: [PATCH 11/13] Bump axios from 0.24.0 to 1.6.0 in /covalent_ui/webapp (#1847) * Bump axios from 0.24.0 to 1.6.0 in /covalent_ui/webapp Bumps [axios](https://github.com/axios/axios) from 0.24.0 to 1.6.0. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v0.24.0...v1.6.0) --- updated-dependencies: - dependency-name: axios dependency-type: direct:production ... Signed-off-by: dependabot[bot] * Updated changelog * Fix failing tests * Update CHANGELOG.md --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Prasy12 Co-authored-by: Sankalp Sanand --- CHANGELOG.md | 4 ++++ covalent_ui/webapp/package.json | 4 ++-- covalent_ui/webapp/yarn.lock | 34 ++++++++++++++++++++++++--------- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffe62a49f..1fe78e1ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - check for `/bin/bash` AND `/bin/sh` (in that order) to execute bash leptons +### Changed + +- Changed the axios version on the webapp side. + ### Operations - Change the strict version pin on `pennylane` from `==0.33.1` to `>=0.31.1,<0.33.0` diff --git a/covalent_ui/webapp/package.json b/covalent_ui/webapp/package.json index 902d3a8ad..988689bb9 100644 --- a/covalent_ui/webapp/package.json +++ b/covalent_ui/webapp/package.json @@ -13,7 +13,7 @@ "@testing-library/react": "^11.1.0", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^12.1.10", - "axios": "^0.24.0", + "axios": "^1.6.0", "clsx": "^1.1.1", "copy-to-clipboard": "^3.3.1", "dagre": "^0.8.5", @@ -56,7 +56,7 @@ }, "jest": { "transformIgnorePatterns": [ - "/node_modules/(?!d3|d3-array|internmap|delaunator|xterm-for-react|xterm-addon-fit|xterm-addon-web-links|xterm-addon-search|robust-predicates)" + "/node_modules/(?!d3|axios|d3-array|internmap|delaunator|xterm-for-react|xterm-addon-fit|xterm-addon-web-links|xterm-addon-search|robust-predicates)" ] }, "browserslist": { diff --git a/covalent_ui/webapp/yarn.lock b/covalent_ui/webapp/yarn.lock index 3e684afd0..f99e4bd61 100644 --- a/covalent_ui/webapp/yarn.lock +++ b/covalent_ui/webapp/yarn.lock @@ -2838,12 +2838,14 @@ axe-core@^4.3.5: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.1.tgz#7dbdc25989298f9ad006645cd396782443757413" integrity sha512-gd1kmb21kwNuWr6BQz8fv6GNECPBnUasepcoLbekws23NVBLODdsClRZ+bQ8+9Uomf3Sm3+Vwn0oYG9NvwnJCw== -axios@^0.24.0: - version "0.24.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.24.0.tgz#804e6fa1e4b9c5288501dd9dff56a7a0940d20d6" - integrity sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA== +axios@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.0.tgz#f1e5292f26b2fd5c2e66876adc5b06cdbd7d2102" + integrity sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg== dependencies: - follow-redirects "^1.14.4" + follow-redirects "^1.15.0" + form-data "^4.0.0" + proxy-from-env "^1.1.0" axobject-query@^2.2.0: version "2.2.0" @@ -5561,10 +5563,10 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" -follow-redirects@^1.0.0, follow-redirects@^1.14.4: - version "1.14.9" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.9.tgz#dd4ea157de7bfaf9ea9b3fbd85aa16951f78d8d7" - integrity sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w== +follow-redirects@^1.0.0, follow-redirects@^1.15.0: + version "1.15.3" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a" + integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q== for-in@^1.0.2: version "1.0.2" @@ -5593,6 +5595,15 @@ form-data@^3.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + format@^0.2.0: version "0.2.2" resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b" @@ -9531,6 +9542,11 @@ proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + prr@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476" From 6c9ce3937a93812e0c75505c232109b9966c6a36 Mon Sep 17 00:00:00 2001 From: CovalentOpsBot Date: Tue, 28 Nov 2023 11:59:07 +0000 Subject: [PATCH 12/13] The new version will be 0.231.0-rc.0 --- CHANGELOG.md | 8 ++++++++ VERSION | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fe78e1ca..1d651d85d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] +## [0.231.0-rc.0] - 2023-11-28 + +### Authors + +- Ara Ghukasyan <38226926+araghukas@users.noreply.github.com> +- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> + + ### Added - check for `/bin/bash` AND `/bin/sh` (in that order) to execute bash leptons diff --git a/VERSION b/VERSION index f14c6116a..c24d4efc7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.230.0-rc.0 \ No newline at end of file +0.231.0-rc.0 \ No newline at end of file From 2e05b04d275005dc9de6143eeb43103e117200fc Mon Sep 17 00:00:00 2001 From: Ara Ghukasyan <38226926+araghukas@users.noreply.github.com> Date: Thu, 30 Nov 2023 01:09:12 -0500 Subject: [PATCH 13/13] Capture terraform errors from deploy command, use scrolling buffer (#1868) * capture terraform errors and outputs * correct leading whitespace * update changelog * ignore "No state file .." message * revise exit return codes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * update tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add tests for coverage * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * check success case in tf version test * update tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * pre-commit fixes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * modify to pass without `terraform` in CI test env * retry tests fix * retry retry fix * remove useless pragma tags * use exist status 1 for error * include crm.up test with 'valid' options * correct exit code * update test * new test for up command with 'valid' options * update to work without terraform install * more changes for CI tests * complete coverage for test_tf_version_error * rename to avoid module vs. function conflict * add comment * add tests for deploy up, down, status * fix typo * fix paths since rename * use importlib for right path of -e install (awsbatch issue) --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- CHANGELOG.md | 6 +- covalent/cloud_resource_manager/core.py | 121 +++++---- covalent_dispatcher/_cli/groups/__init__.py | 4 +- .../_cli/groups/{db.py => db_group.py} | 0 .../groups/{deploy.py => deploy_group.py} | 143 +++++------ .../groups/deploy_group_print_callbacks.py | 99 ++++++++ .../_cli/cli_test.py | 205 ++++++++++++++++ .../_cli/groups/db_test.py | 10 +- .../cloud_resource_manager/core_test.py | 230 ++++++++++++++---- 9 files changed, 617 insertions(+), 201 deletions(-) rename covalent_dispatcher/_cli/groups/{db.py => db_group.py} (100%) rename covalent_dispatcher/_cli/groups/{deploy.py => deploy_group.py} (70%) create mode 100644 covalent_dispatcher/_cli/groups/deploy_group_print_callbacks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d651d85d..f125abfa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] +### Changed + +- Terraform output to use scrolling buffer. +- Terraform output handling to show errors. + ## [0.231.0-rc.0] - 2023-11-28 ### Authors @@ -14,7 +19,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ara Ghukasyan <38226926+araghukas@users.noreply.github.com> - Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> - ### Added - check for `/bin/bash` AND `/bin/sh` (in that order) to execute bash leptons diff --git a/covalent/cloud_resource_manager/core.py b/covalent/cloud_resource_manager/core.py index 035efd412..4721fb70c 100644 --- a/covalent/cloud_resource_manager/core.py +++ b/covalent/cloud_resource_manager/core.py @@ -121,9 +121,9 @@ def get_plugin_settings( for key, value in executor_options.items(): try: settings_dict[key]["value"] = value - except: + except Exception: logger.error(f"No such option '{key}'. Use --help for available options") - sys.exit() + sys.exit(1) return settings_dict @@ -164,10 +164,9 @@ def __init__( self._terraform_log_env_vars = { "TF_LOG": "ERROR", "TF_LOG_PATH": os.path.join(self.executor_tf_path, "terraform-error.log"), - "PATH": "$PATH:/usr/bin", } - def _print_stdout(self, process: subprocess.Popen, print_callback: Callable) -> int: + def _poll_process(self, process: subprocess.Popen, print_callback: Callable) -> int: """ Print the stdout from the subprocess to console @@ -179,12 +178,10 @@ def _print_stdout(self, process: subprocess.Popen, print_callback: Callable) -> Return code of the process. """ - while (retcode := process.poll()) is None: - if (proc_stdout := process.stdout.readline()) and print_callback: - print_callback(proc_stdout.strip().decode("utf-8")) - return retcode - - # TODO: Return the command output along with return code + while (returncode := process.poll()) is None: + if print_callback: + print_callback(process.stdout.readline()) + return returncode def _parse_terraform_error_log(self) -> List[str]: """Parse the terraform error logs. @@ -235,16 +232,21 @@ def _get_resource_status( Returns: status: str - status of plugin """ - _, stderr = proc.communicate() + cmds = cmd.split(" ") tfstate_path = cmds[-1].split("=")[-1] - if stderr is None: - return self._terraform_error_validator(tfstate_path=tfstate_path) - else: - raise subprocess.CalledProcessError( - returncode=1, cmd=cmd, stderr=self._parse_terraform_error_log() + + returncode = self._poll_process(proc, print_callback=None) + stderr = proc.stderr.read() + if returncode != 0 and "No state file was found!" not in stderr: + print( + "Unable to get resource status due to the following error:\n\n", + stderr, + file=sys.stderr, ) + return self._terraform_error_validator(tfstate_path=tfstate_path) + def _log_error_msg(self, cmd) -> None: """ Log error msg with valid command to terraform-erro.log @@ -261,7 +263,6 @@ def _log_error_msg(self, cmd) -> None: def _run_in_subprocess( self, cmd: str, - workdir: str, env_vars: Optional[Dict[str, str]] = None, print_callback: Optional[Callable] = None, ) -> Union[None, str]: @@ -270,39 +271,50 @@ def _run_in_subprocess( Args: cmd: Command to execute in the subprocess - workdir: Working directory of the subprocess env_vars: Dictionary of environment variables to set in the processes execution environment Returns: Union[None, str] - For 'covalent deploy status' - returns status of the deplyment + returns status of the deployment - Others return None """ - if git := shutil.which("git"): - proc = subprocess.Popen( - args=cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - cwd=workdir, - shell=True, - env=env_vars, - ) - TERRAFORM_STATE = "state list -state" - if TERRAFORM_STATE in cmd: - return self._get_resource_status(proc=proc, cmd=cmd) - retcode = self._print_stdout(proc, print_callback) - - if retcode != 0: - self._log_error_msg(cmd=cmd) - raise subprocess.CalledProcessError( - returncode=retcode, cmd=cmd, stderr=self._parse_terraform_error_log() - ) - else: + if not shutil.which("git"): self._log_error_msg(cmd=cmd) logger.error("Git not found on the system.") - sys.exit() + sys.exit(1) + + env_vars = env_vars or {} + env_vars.update({"PATH": os.environ["PATH"]}) + + proc = subprocess.Popen( + args=cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=self.executor_tf_path, + universal_newlines=True, + shell=True, + env=env_vars, + ) + + if "state list -state" in cmd: + return self._get_resource_status(proc=proc, cmd=cmd) + + returncode = self._poll_process(proc, print_callback) + + if returncode != 0: + self._log_error_msg(cmd=cmd) + + _, stderr = proc.communicate() + raise subprocess.CalledProcessError( + returncode=returncode, + cmd=cmd, + stderr=self._parse_terraform_error_log(), + output=stderr, + ) + + return None def _update_config(self, tf_executor_config_file: str) -> None: """ @@ -348,18 +360,22 @@ def _get_tf_path(self) -> str: """ if terraform := shutil.which("terraform"): result = subprocess.run( - ["terraform --version"], shell=True, capture_output=True, text=True + ["terraform --version"], + shell=True, + capture_output=True, + text=True, + check=True, ) version = result.stdout.split("v", 1)[1][:3] if float(version) < 1.4: logger.error( "Old version of terraform found. Please update it to version greater than 1.3" ) - sys.exit() + sys.exit(1) return terraform - else: - logger.error("Terraform not found on system") - exit() + + logger.error("Terraform not found on system") + sys.exit(1) def _get_tf_statefile_path(self) -> str: """ @@ -401,14 +417,16 @@ def up(self, print_callback: Callable, dry_run: bool = True) -> None: # Run `terraform init` self._run_in_subprocess( - cmd=tf_init, workdir=self.executor_tf_path, env_vars=self._terraform_log_env_vars + cmd=tf_init, + env_vars=self._terraform_log_env_vars, + print_callback=print_callback, ) # Setup terraform infra variables as passed by the user tf_vars_env_dict = os.environ.copy() if self.executor_options: - with open(tfvars_file, "w") as f: + with open(tfvars_file, "w", encoding="utf-8") as f: for key, value in self.executor_options.items(): tf_vars_env_dict[f"TF_VAR_{key}"] = value @@ -418,7 +436,6 @@ def up(self, print_callback: Callable, dry_run: bool = True) -> None: # Run `terraform plan` self._run_in_subprocess( cmd=tf_plan, - workdir=self.executor_tf_path, env_vars=self._terraform_log_env_vars, print_callback=print_callback, ) @@ -426,9 +443,8 @@ def up(self, print_callback: Callable, dry_run: bool = True) -> None: # Create infrastructure as per the plan # Run `terraform apply` if not dry_run: - cmd_output = self._run_in_subprocess( + self._run_in_subprocess( cmd=tf_apply, - workdir=self.executor_tf_path, env_vars=tf_vars_env_dict.update(self._terraform_log_env_vars), print_callback=print_callback, ) @@ -472,7 +488,6 @@ def down(self, print_callback: Callable) -> None: # Run `terraform destroy` self._run_in_subprocess( cmd=tf_destroy, - workdir=self.executor_tf_path, print_callback=print_callback, env_vars=self._terraform_log_env_vars, ) @@ -510,6 +525,4 @@ def status(self) -> None: tf_state = " ".join([terraform, "state", "list", f"-state={tf_state_file}"]) # Run `terraform state list` - return self._run_in_subprocess( - cmd=tf_state, workdir=self.executor_tf_path, env_vars=self._terraform_log_env_vars - ) + return self._run_in_subprocess(cmd=tf_state, env_vars=self._terraform_log_env_vars) diff --git a/covalent_dispatcher/_cli/groups/__init__.py b/covalent_dispatcher/_cli/groups/__init__.py index 3eee4cd19..3ae5d8d46 100644 --- a/covalent_dispatcher/_cli/groups/__init__.py +++ b/covalent_dispatcher/_cli/groups/__init__.py @@ -13,5 +13,5 @@ # 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 .db import db -from .deploy import deploy +from .db_group import db +from .deploy_group import deploy diff --git a/covalent_dispatcher/_cli/groups/db.py b/covalent_dispatcher/_cli/groups/db_group.py similarity index 100% rename from covalent_dispatcher/_cli/groups/db.py rename to covalent_dispatcher/_cli/groups/db_group.py diff --git a/covalent_dispatcher/_cli/groups/deploy.py b/covalent_dispatcher/_cli/groups/deploy_group.py similarity index 70% rename from covalent_dispatcher/_cli/groups/deploy.py rename to covalent_dispatcher/_cli/groups/deploy_group.py index d6fb85ff1..a9510c09f 100644 --- a/covalent_dispatcher/_cli/groups/deploy.py +++ b/covalent_dispatcher/_cli/groups/deploy_group.py @@ -17,10 +17,12 @@ """Covalent deploy CLI group.""" - import subprocess +import sys +from functools import partial +from importlib import import_module from pathlib import Path -from typing import Dict, Tuple +from typing import Callable, Dict, Tuple import boto3 import click @@ -30,9 +32,9 @@ from covalent.cloud_resource_manager.core import CloudResourceManager from covalent.executor import _executor_manager -RESOURCE_ALREADY_EXISTS = "Resources already deployed" -RESOURCE_ALREADY_DESTROYED = "Resources already destroyed" -COMPLETED = "Completed" +from .deploy_group_print_callbacks import ScrollBufferCallback + +_TEMPLATE = "[bold green]{message} [default]\n {text}" def get_crm_object(executor_name: str, options: Dict = None) -> CloudResourceManager: @@ -43,36 +45,11 @@ def get_crm_object(executor_name: str, options: Dict = None) -> CloudResourceMan CloudResourceManager object. """ - executor_module_path = Path( - __import__(_executor_manager.executor_plugins_map[executor_name].__module__).__path__[0] - ) + executor_plugin = _executor_manager.executor_plugins_map[executor_name] + executor_module_path = Path(import_module(executor_plugin.__module__).__file__).parent return CloudResourceManager(executor_name, executor_module_path, options) -def get_print_callback( - console: Console, console_status: Console.status, prepend_msg: str, verbose: bool -): - """Get print callback method. - - Args: - console: Rich console object. - console_status: Console status object. - prepend_msg: Message to prepend to the output. - verbose: Whether to print the output inline or not. - - Returns: - Callback method. - - """ - if verbose: - return console.print - - def inline_print_callback(msg): - console_status.update(f"{prepend_msg} {msg}") - - return inline_print_callback - - def get_settings_table(crm: CloudResourceManager) -> Table: """Get resource provisioning settings table. @@ -116,6 +93,43 @@ def get_up_help_table(crm: CloudResourceManager) -> Table: return table +def _run_command_and_show_output( + _command: Callable[[Callable], None], _status_message: str, *, verbose: bool +) -> None: + """Run the command and show the output in the console. + + This function handles execution and outputs from the `up` and `down` commands. + + Args: + _command: command to run, e.g. `partial(crm.up, dry_run=dry_run)` + _status_message: message to show in the console status bar, e.g. "Provisioning resources..." + verbose: whether to show the full Terraform output or not. + """ + console = Console(record=True) + msg_template = _TEMPLATE.format(message=_status_message, text="{text}") + + with console.status(msg_template.format(text="")) as console_status: + print_callback = ScrollBufferCallback( + console=console, + console_status=console_status, + msg_template=msg_template, + verbose=verbose, + ) + + try: + _command(print_callback=print_callback) + + except subprocess.CalledProcessError as e: + console_status.stop() + click.echo(e.stdout) # display error + sys.exit(1) + + if not verbose: + console_status.stop() + if (complete_msg := print_callback.complete_msg) is not None: + console.print("\n", complete_msg, style="bold green") + + @click.group(invoke_without_command=True) def deploy(): """ @@ -130,7 +144,6 @@ def deploy(): 4. Show status of all resources via `covalent deploy status`. """ - pass @deploy.command(context_settings={"ignore_unknown_options": True}) @@ -165,39 +178,16 @@ def up(executor_name: str, vars: Dict, help: bool, dry_run: bool, verbose: bool) """ cmd_options = {key[2:]: value for key, value in (var.split("=") for var in vars)} if msg := validate_args(cmd_options): + # Message is not None, so there was an error. click.echo(msg) - return + sys.exit(1) crm = get_crm_object(executor_name, cmd_options) if help: click.echo(Console().print(get_up_help_table(crm))) - return - - console = Console(record=True) - prepend_msg = "[bold green] Provisioning resources..." - - with console.status(prepend_msg) as status: - try: - crm.up( - dry_run=dry_run, - print_callback=get_print_callback( - console=console, - console_status=status, - prepend_msg=prepend_msg, - verbose=verbose, - ), - ) - except subprocess.CalledProcessError as e: - click.echo(f"Unable to provision resources due to the following error:\n\n{e}") - return + sys.exit(0) - click.echo(Console().print(get_settings_table(crm))) - exists_msg_with_verbose = "Apply complete! Resources: 0 added, 0 changed, 0 destroyed" - exists_msg_without_verbose = "found no differences, so no changes are needed" - export_data = console.export_text() - if exists_msg_with_verbose in export_data or exists_msg_without_verbose in export_data: - click.echo(RESOURCE_ALREADY_EXISTS) - else: - click.echo(COMPLETED) + _command = partial(crm.up, dry_run=dry_run) + _run_command_and_show_output(_command, "Provisioning resources...", verbose=verbose) @deploy.command() @@ -223,28 +213,8 @@ def down(executor_name: str, verbose: bool) -> None: """ crm = get_crm_object(executor_name) - - console = Console(record=True) - prepend_msg = "[bold green] Destroying resources..." - with console.status(prepend_msg) as status: - try: - crm.down( - print_callback=get_print_callback( - console=console, - console_status=status, - prepend_msg=prepend_msg, - verbose=verbose, - ) - ) - except subprocess.CalledProcessError as e: - click.echo(f"Unable to destroy resources due to the following error:\n\n{e}") - return - destroyed_msg = "Destroy complete! Resources: 0 destroyed." - export_data = console.export_text() - if destroyed_msg in export_data: - click.echo(RESOURCE_ALREADY_DESTROYED) - else: - click.echo(COMPLETED) + _command = partial(crm.down) + _run_command_and_show_output(_command, "Destroying resources...", verbose=verbose) # TODO - Color code status. @@ -271,11 +241,10 @@ def status(executor_names: Tuple[str]) -> None: "*up": "Warning: Provisioning error, retry 'up'.", "*down": "Warning: Teardown error, retry 'down'.", } - if not executor_names: executor_names = [ name - for name in _executor_manager.executor_plugins_map.keys() + for name in _executor_manager.executor_plugins_map if name not in ["dask", "local", "remote_executor"] ] click.echo(f"Executors: {', '.join(executor_names)}") @@ -289,8 +258,8 @@ def status(executor_names: Tuple[str]) -> None: for executor_name in executor_names: try: crm = get_crm_object(executor_name) - status = crm.status() - table.add_row(executor_name, status, description[status]) + crm_status = crm.status() + table.add_row(executor_name, crm_status, description[crm_status]) except KeyError: invalid_executor_names.append(executor_name) diff --git a/covalent_dispatcher/_cli/groups/deploy_group_print_callbacks.py b/covalent_dispatcher/_cli/groups/deploy_group_print_callbacks.py new file mode 100644 index 000000000..949e5ceb4 --- /dev/null +++ b/covalent_dispatcher/_cli/groups/deploy_group_print_callbacks.py @@ -0,0 +1,99 @@ +# Copyright 2023 Agnostiq Inc. +# +# This file is part of Covalent. +# +# Licensed under the Apache License 2.0 (the "License"). A copy of the +# License may be obtained with this software package or at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Use of this file is prohibited except in compliance with the License. +# 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. + + +"""Print callbacks for deploy up and deploy down commands.""" + +import re +from typing import Union + +from rich.console import Console +from rich.status import Status + + +class ScrollBufferCallback: + """Callable print callback that refreshes a buffer of length `max_lines`""" + + _complete_msg_pattern = re.compile( + r"^(Apply complete\! Resources: \d+ added, \d+ changed, \d+ destroyed\." + "|" + r"Destroy complete\! Resources: \d+ destroyed\.)$" + ) + + def __init__( + self, + console: Console, + console_status: Status, + msg_template: str, + verbose: bool, + max_lines: int = 12, + ): + """Create a new scroll buffer callback. + + Args: + console: Rich console object. + console_status: Rich console status object. + msg_template: Message template pre-formatted with provision or destroy message. + verbose: Whether to print the output inline or not. + max_lines: Number of lines in the buffer. Defaults to 5. + """ + self.console = console + self.console_status = console_status + self.msg_template = msg_template + self.verbose = verbose + self.max_lines = max_lines + self.buffer = [] + + self._complete_msg = None + + @property + def complete_msg(self) -> Union[str, None]: + """Return a complete message matching: + + 'Apply complete! Resources: 19 added, 0 changed, 0 destroyed.' + or + 'Destroy complete! Resources: 19 destroyed.' + + Returns: + The complete message, if it exists, else None. + """ + return self._complete_msg + + def __call__(self, msg: str): + if self.verbose: + self._verbose_print(msg) + else: + self._inline_print(msg) + + def _inline_print(self, msg: str) -> None: + """Print into a scrolling buffer of size `self.max_lines`.""" + if len(self.buffer) > self.max_lines: + self.buffer.pop(0) + self.buffer.append(msg) + + if self._complete_msg_pattern.match(msg): + self._complete_msg = msg + + text = self.render_buffer() + self.console_status.update(self.msg_template.format(text=text)) + + def _verbose_print(self, msg: str) -> None: + """Print normally, line by line.""" + print(msg.rstrip() if msg != "\n" else msg) + + def render_buffer(self, sep: str = " ") -> str: + """Render the current buffer as a string.""" + return sep.join(self.buffer) diff --git a/tests/covalent_dispatcher_tests/_cli/cli_test.py b/tests/covalent_dispatcher_tests/_cli/cli_test.py index a8b546e90..46d056b1f 100644 --- a/tests/covalent_dispatcher_tests/_cli/cli_test.py +++ b/tests/covalent_dispatcher_tests/_cli/cli_test.py @@ -18,7 +18,10 @@ """Test for Covalent CLI Tool.""" +import subprocess + import click +import pytest from click.testing import CliRunner from covalent_dispatcher._cli.cli import cli @@ -65,3 +68,205 @@ def test_cli_commands(): "status", "stop", ] + + +@pytest.mark.parametrize( + ("error", "verbose"), + [ + (False, False), + (False, True), + (True, False), + (True, True), + ], +) +def test_run_command_and_show_output(mocker, error, verbose): + """ + Unit test for `_run_command_and_show_output` function. + + Test that errors are raised and messages printed when expected. + """ + from covalent_dispatcher._cli.groups.deploy_group import _run_command_and_show_output + + mock_console_print = mocker.patch("rich.console.Console.print") + mock_click_echo = mocker.patch("click.echo") + mocker.patch( + "covalent_dispatcher._cli.groups.deploy_group_print_callbacks.ScrollBufferCallback.complete_msg", + return_value="Apply complete! Resources: 19 added, 0 changed, 0 destroyed.", + ) + + def _command(*args, **kwargs): + _command.calls += 1 + _output = "Testing Error..." + _error = subprocess.CalledProcessError(1, "terraform", _output) + if error: + raise _error + + _command.calls = 0 + + if error: + msg = "Testing Error..." + with pytest.raises(SystemExit): + _run_command_and_show_output(_command, msg, verbose=verbose) + else: + msg = "Testing Success..." + _run_command_and_show_output(_command, msg, verbose=verbose) + + if error: + mock_click_echo.assert_called_once_with("Testing Error...") + elif not verbose: + mock_console_print.assert_called_once() + else: + assert _command.calls == 1 + + +@pytest.mark.parametrize( + ("verbose", "mode"), + [ + (False, "provision"), + (False, "destroy"), + (True, "provision"), + (True, "destroy"), + ], +) +def test_scroll_buffer_print_callback(mocker, verbose, mode): + """ + Unit test for the custom buffered print callback. + """ + from rich.console import Console + from rich.status import Status + + from covalent_dispatcher._cli.groups.deploy_group import _TEMPLATE + from covalent_dispatcher._cli.groups.deploy_group_print_callbacks import ScrollBufferCallback + + console = Console(record=True) + console_status = Status("Testing...", console=console) + + mock_print = mocker.patch("covalent_dispatcher._cli.groups.deploy_group_print_callbacks.print") + mock_console_status_update = mocker.patch("rich.status.Status.update") + + print_callback = ScrollBufferCallback( + console=console, + console_status=console_status, + msg_template=_TEMPLATE.format(message="Testing...", text="{text}"), + verbose=verbose, + max_lines=3, # shorten to hit pop inside `._inline_print` + ) + + complete_msg = ( + "Apply complete! Resources: 19 added, 0 changed, 0 destroyed." + if mode == "provision" + else "Destroy complete! Resources: 19 destroyed." + ) + messages = ["fake", "fake", "fake", complete_msg, "fake", "fake", "fake"] + + for msg in messages: + print_callback(msg) + if verbose: + mock_print.assert_called_with(msg) + else: + mock_console_status_update.assert_called_with( + print_callback.msg_template.format(text=print_callback.render_buffer()) + ) + + if not verbose: + assert print_callback.complete_msg == complete_msg + + +def test_deploy_up(mocker): + """ + Unit test for `covalent deploy up [executor_name]` command. + """ + + from covalent_dispatcher._cli.groups.deploy_group import up + + # Patch function that executes commands. + mock_run_command_and_show_output = mocker.patch( + "covalent_dispatcher._cli.groups.deploy_group._run_command_and_show_output", + ) + + # Fail with invalid command options. + mocker.patch( + "covalent_dispatcher._cli.groups.deploy_group.validate_args", + return_value="Non-empty msg", + ) + with pytest.raises(SystemExit) as exc_info: + ctx = click.Context(up) + ctx.invoke(up) + + assert exc_info.value.code == 1 + + # Succeed but exit after help message. + mocker.patch( + "covalent_dispatcher._cli.groups.deploy_group.validate_args", + return_value=None, + ) + mocker.patch( + "covalent_dispatcher._cli.groups.deploy_group.get_crm_object", + ) + with pytest.raises(SystemExit) as exc_info: + ctx = click.Context(up) + ctx.invoke(up, help=True) + + assert exc_info.value.code == 0 + + # Succeed with valid command options. + ctx = click.Context(up) + ctx.invoke(up, verbose=True) + + mock_run_command_and_show_output.assert_called_once() + + +def test_deploy_down(mocker): + """ + Unit test for `covalent deploy down [executor_name]` command. + """ + + from covalent_dispatcher._cli.groups.deploy_group import down + + # Patch function that executes commands. + mock_run_command_and_show_output = mocker.patch( + "covalent_dispatcher._cli.groups.deploy_group._run_command_and_show_output", + ) + mocker.patch( + "covalent_dispatcher._cli.groups.deploy_group.get_crm_object", + ) + + ctx = click.Context(down) + ctx.invoke(down, verbose=True) + mock_run_command_and_show_output.assert_called_once() + + +def test_deploy_status(mocker): + """ + Unit test for `covalent deploy status [executor_name]` command. + """ + + from covalent_dispatcher._cli.groups.deploy_group import status + + # Succeed with empty `executor_names` argument. + ctx = click.Context(status) + ctx.invoke(status, executor_names=[]) + + # Succeed with invalid `executor_names` argument. + mock_click_style = mocker.patch("click.style") + + ctx = click.Context(status) + ctx.invoke(status, executor_names=["invalid"]) + + mock_click_style.assert_called_once() + + # Succeed with 'valid' `executor_names` argument. + mocker.patch( + "covalent_dispatcher._cli.groups.deploy_group.get_crm_object", + ) + mocker.patch( + "covalent.cloud_resource_manager.core.CloudResourceManager.status", + return_value="down", + ) + + mock_console_print = mocker.patch("rich.console.Console.print") + + ctx = click.Context(status) + ctx.invoke(status, executor_names=["awsbatch"]) # OK if not installed + + mock_console_print.assert_called_once() diff --git a/tests/covalent_dispatcher_tests/_cli/groups/db_test.py b/tests/covalent_dispatcher_tests/_cli/groups/db_test.py index 80cd42eec..a5b17bdd4 100644 --- a/tests/covalent_dispatcher_tests/_cli/groups/db_test.py +++ b/tests/covalent_dispatcher_tests/_cli/groups/db_test.py @@ -18,7 +18,7 @@ from click.testing import CliRunner -from covalent_dispatcher._cli.groups.db import MIGRATION_WARNING_MSG, alembic, migrate +from covalent_dispatcher._cli.groups.db_group import MIGRATION_WARNING_MSG, alembic, migrate from covalent_dispatcher._db.datastore import DataStore @@ -46,7 +46,9 @@ def test_alembic_command_args(mocker): MOCK_ALEMBIC_ARGS_INVALID = "some alembic command --with-flags -m 'comment'" ALEMBIC_ERROR_STDERR = b"alembic: error: invalid..." ALEMBIC_ERROR_STDOUT = b"b60c5 (head)" - popen_mock = mocker.patch.object(sys.modules["covalent_dispatcher._cli.groups.db"], "Popen") + popen_mock = mocker.patch.object( + sys.modules["covalent_dispatcher._cli.groups.db_group"], "Popen" + ) # test valid alembic args popen_mock().communicate.return_value = (ALEMBIC_ERROR_STDOUT, b"") res = runner.invoke(alembic, MOCK_ALEMBIC_ARGS_VALID, catch_exceptions=False) @@ -61,7 +63,9 @@ def test_alembic_not_installed_error(mocker): runner = CliRunner() MOCK_ALEMBIC_ARGS = "current" EXCEPTION_MESSAGE = "[Errno 2] No such file or directory: 'alembic'" - popen_mock = mocker.patch.object(sys.modules["covalent_dispatcher._cli.groups.db"], "Popen") + popen_mock = mocker.patch.object( + sys.modules["covalent_dispatcher._cli.groups.db_group"], "Popen" + ) popen_mock.side_effect = FileNotFoundError(EXCEPTION_MESSAGE) res = runner.invoke(alembic, MOCK_ALEMBIC_ARGS, catch_exceptions=False) assert EXCEPTION_MESSAGE in res.output diff --git a/tests/covalent_tests/cloud_resource_manager/core_test.py b/tests/covalent_tests/cloud_resource_manager/core_test.py index ea2ef058a..7fdca350d 100644 --- a/tests/covalent_tests/cloud_resource_manager/core_test.py +++ b/tests/covalent_tests/cloud_resource_manager/core_test.py @@ -16,6 +16,7 @@ import os import subprocess +import tempfile from configparser import ConfigParser from functools import partial from pathlib import Path @@ -56,6 +57,35 @@ def crm(mocker, executor_name, executor_module_path): ) +class _FakeIO: + """Mocks process stdout and stderr.""" + + def __init__(self, message): + self.message = message + + def read(self): + return self.message + + def readline(self): + return self.read() + + +class _FakeProc: + """Mocks process""" + + def __init__(self, returncode, stdout="", stderr="", fake_stream=True): + self.returncode = returncode + self.args = () + self.stdout = _FakeIO(stdout) if fake_stream else stdout + self.stderr = _FakeIO(stderr) if fake_stream else stderr + + def poll(self): + return self.returncode + + def communicate(self): + return self.stdout.read(), self.stderr.read() + + def test_get_executor_module(mocker): """ Unit test for get_executor_module method @@ -163,9 +193,9 @@ def test_cloud_resource_manager_init(mocker, options, executor_name, executor_mo ) -def test_print_stdout(mocker, crm): +def test_poll_process(mocker, crm): """ - Unit test for CloudResourceManager._print_stdout() method + Unit test for CloudResourceManager._poll_process() method """ test_stdout = "test_stdout".encode("utf-8") @@ -177,7 +207,7 @@ def test_print_stdout(mocker, crm): mock_process.stdout.readline.side_effect = partial(next, iter([test_stdout, None])) mock_print = mocker.patch("covalent.cloud_resource_manager.core.print") - return_code = crm._print_stdout( + return_code = crm._poll_process( mock_process, print_callback=mock_print( test_stdout.decode("utf-8"), @@ -203,17 +233,15 @@ def test_run_in_subprocess(mocker, test_retcode, crm): """ test_cmd = "test_cmd" - test_workdir = "test_workdir" test_env_vars = {"test_env_key": "test_env_value"} - mock_process = mocker.MagicMock() mock_popen = mocker.patch( "covalent.cloud_resource_manager.core.subprocess.Popen", - return_value=mock_process, + return_value=_FakeProc(test_retcode), ) - mock_print_stdout = mocker.patch( - "covalent.cloud_resource_manager.core.CloudResourceManager._print_stdout", + mocker.patch( + "covalent.cloud_resource_manager.core.CloudResourceManager._poll_process", return_value=int(test_retcode), ) @@ -222,37 +250,33 @@ def test_run_in_subprocess(mocker, test_retcode, crm): ) mocker.patch( "covalent.cloud_resource_manager.core.CloudResourceManager._log_error_msg", - return_value=None, - side_effect=None, ) if test_retcode != 0: exception = subprocess.CalledProcessError(returncode=test_retcode, cmd=test_cmd) - print("sam exception ", exception) - with pytest.raises(Exception, match=str(exception)): + print("some exception ", exception) + with pytest.raises(subprocess.CalledProcessError) as excinfo: crm._run_in_subprocess( cmd=test_cmd, - workdir=test_workdir, env_vars=test_env_vars, ) + # Errors are contained in the output for printing. + assert excinfo.value.output == "some exception " else: crm._run_in_subprocess( cmd=test_cmd, - workdir=test_workdir, env_vars=test_env_vars, ) mock_popen.assert_called_once_with( args=test_cmd, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - cwd=test_workdir, + stderr=subprocess.PIPE, + cwd=crm.executor_tf_path, + universal_newlines=True, shell=True, env=test_env_vars, ) - # print("sam mocker process : ", mock_process) - # print("sam mocker print : ", mock_print_stdout) - # mock_print_stdout.assert_called_once_with(mock_process) def test_update_config(mocker, crm, executor_name): @@ -361,14 +385,14 @@ def test_up(mocker, dry_run, executor_options, executor_name, executor_module_pa test_tf_path = "test_tf_path" test_tf_state_file = "test_tf_state_file" - mock_get_tf_path = mocker.patch( + mocker.patch( "covalent.cloud_resource_manager.core.CloudResourceManager._get_tf_path", return_value=test_tf_path, ) mocker.patch( "covalent.cloud_resource_manager.core.CloudResourceManager._validation_docker", ) - mock_get_tf_statefile_path = mocker.patch( + mocker.patch( "covalent.cloud_resource_manager.core.CloudResourceManager._get_tf_statefile_path", return_value=test_tf_state_file, ) @@ -402,7 +426,7 @@ def test_up(mocker, dry_run, executor_options, executor_name, executor_module_pa if executor_options: with pytest.raises(SystemExit): - crm = CloudResourceManager( + CloudResourceManager( executor_name=executor_name, executor_module_path=executor_module_path, options=executor_options, @@ -420,42 +444,26 @@ def test_up(mocker, dry_run, executor_options, executor_name, executor_module_pa ) as mock_file: crm.up(dry_run=dry_run, print_callback=None) - env_vars = { - "PATH": "$PATH:/usr/bin", - "TF_LOG": "ERROR", - "TF_LOG_PATH": os.path.join(crm.executor_tf_path + "/terraform-error.log"), - } # mock_get_tf_path.assert_called_once() init_cmd = f"{test_tf_path} init" mock_run_in_subprocess.assert_any_call( cmd=init_cmd, - workdir=crm.executor_tf_path, - env_vars=env_vars, - # print_callback=None, + env_vars=crm._terraform_log_env_vars, + print_callback=None, ) mock_environ_copy.assert_called_once() - if executor_options: - mock_file.assert_called_once_with( - f"{crm.executor_tf_path}/terraform.tfvars", - "w", - ) - - key, value = list(executor_options.items())[0] - mock_file().write.assert_called_once_with(f'{key}="{value}"\n') mock_run_in_subprocess.assert_any_call( cmd=f"{test_tf_path} plan -out tf.plan", # -state={test_tf_state_file}", - workdir=crm.executor_tf_path, - env_vars=env_vars, + env_vars=crm._terraform_log_env_vars, print_callback=None, ) if not dry_run: mock_run_in_subprocess.assert_any_call( cmd=f"{test_tf_path} apply tf.plan -state={test_tf_state_file}", - workdir=crm.executor_tf_path, - env_vars=env_vars, + env_vars=crm._terraform_log_env_vars, print_callback=None, ) @@ -464,6 +472,68 @@ def test_up(mocker, dry_run, executor_options, executor_name, executor_module_pa ) +def test_up_executor_options(mocker, executor_name, executor_module_path): + """ + Unit test for CloudResourceManager.up() method with executor options. + + Test expected behavior with 'valid' options. Note that *actual* valid options + require executor plugins to be installed, so not suitable for CI tests. + """ + # Disable validation. + mocker.patch( + "covalent.cloud_resource_manager.core.validate_options", + return_value=None, + ) + mocker.patch( + "covalent.cloud_resource_manager.core.CloudResourceManager._validation_docker", + ) + + # Disable actually finding executor. + mocker.patch( + "covalent.cloud_resource_manager.core.get_executor_module", + ) + + # Disable plugin settings. + mocker.patch( + "covalent.cloud_resource_manager.core.get_plugin_settings", + return_value={}, + ) + + # Disable path checks so nothing deleted (as it would be, if exists). + mocker.patch("covalent.cloud_resource_manager.core.Path.exists", return_value=False) + + # Disable _run_in_subprocess to avoid side effects. + mocker.patch( + "covalent.cloud_resource_manager.core.CloudResourceManager._run_in_subprocess", + ) + + # Disable _update_config to avoid side effects. + mocker.patch( + "covalent.cloud_resource_manager.core.CloudResourceManager._update_config", + ) + + # For CI tests, pretend homebrew exists. + mocker.patch("shutil.which", return_value="/opt/homebrew/bin/terraform") + mocker.patch( + "covalent.cloud_resource_manager.core.subprocess.run", + return_value=_FakeProc(0, stdout="v99.99", fake_stream=False), + ) + + crm = CloudResourceManager( + executor_name=executor_name, + executor_module_path=executor_module_path, + options={"test_key": "test_value"}, + ) + + with tempfile.TemporaryDirectory() as d: + # Create fake vars file to avoid side effects. + fake_tfvars_file = Path(d) / "terraform.tfvars" + fake_tfvars_file.touch() + + crm.executor_tf_path = d + crm.up(dry_run=False, print_callback=None) + + def test_down(mocker, crm): """ Unit test for CloudResourceManager.down() method. @@ -471,7 +541,6 @@ def test_down(mocker, crm): test_tf_path = "test_tf_path" test_tf_state_file = "test_tf_state_file" - test_tf_log_file = "terraform-error.log" mock_get_tf_path = mocker.patch( "covalent.cloud_resource_manager.core.CloudResourceManager._get_tf_path", @@ -521,10 +590,8 @@ def test_down(mocker, crm): "-auto-approve", ] ) - env_vars = {"PATH": "$PATH:/usr/bin", "TF_LOG": "ERROR", "TF_LOG_PATH": log_file_path} - mock_run_in_subprocess.assert_called_once_with( - cmd=cmd, print_callback=None, workdir=crm.executor_tf_path, env_vars=env_vars - ) + env_vars = crm._terraform_log_env_vars + mock_run_in_subprocess.assert_called_once_with(cmd=cmd, print_callback=None, env_vars=env_vars) assert mock_path_exists.call_count == 5 assert mock_path_unlink.call_count == 4 @@ -537,7 +604,6 @@ def test_status(mocker, crm): test_tf_path = "test_tf_path" test_tf_state_file = "test_tf_state_file" - log_file_path = os.path.join(crm.executor_tf_path + "/terraform-error.log") mock_get_tf_path = mocker.patch( "covalent.cloud_resource_manager.core.CloudResourceManager._get_tf_path", return_value=test_tf_path, @@ -548,20 +614,76 @@ def test_status(mocker, crm): return_value=test_tf_state_file, ) + mock_terraform_error_validator = mocker.patch( + "covalent.cloud_resource_manager.core.CloudResourceManager._terraform_error_validator", + ) + mocker.patch( "covalent.cloud_resource_manager.core.CloudResourceManager._validation_docker", ) - mock_run_in_subprocess = mocker.patch( - "covalent.cloud_resource_manager.core.CloudResourceManager._run_in_subprocess", + mocker.patch( + "covalent.cloud_resource_manager.core.subprocess.Popen", ) crm.status() mock_get_tf_path.assert_called_once() mock_get_tf_statefile_path.assert_called_once() - mock_run_in_subprocess.assert_called_once_with( - cmd=f"{test_tf_path} state list -state={test_tf_state_file}", - workdir=crm.executor_tf_path, - env_vars={"PATH": "$PATH:/usr/bin", "TF_LOG": "ERROR", "TF_LOG_PATH": log_file_path}, + mock_terraform_error_validator.assert_called_once_with(tfstate_path=test_tf_state_file) + + +def test_crm_get_resource_status(mocker, crm): + """ + Unit test for CloudResourceManager._get_resource_status() method. + + Test that errors while getting resource status don't exit, rather print and report status. + """ + + mock_terraform_error_validator = mocker.patch( + "covalent.cloud_resource_manager.core.CloudResourceManager._terraform_error_validator", ) + mock_print = mocker.patch( + "covalent.cloud_resource_manager.core.print", + ) + + crm._get_resource_status(proc=_FakeProc(1), cmd="fake command") + mock_print.assert_called_once() + mock_terraform_error_validator.assert_called_once() + + +def test_no_git(crm, mocker): + """ + Test for exit with status 1 if `git` is not available. + """ + + mocker.patch("shutil.which", return_value=None) + mocker.patch("covalent.cloud_resource_manager.CloudResourceManager._log_error_msg") + + with pytest.raises(SystemExit): + crm._run_in_subprocess("fake command") + + +def test_tf_version_error(mocker, crm): + """ + Unit test for CloudResourceManager._get_tf_path() method. + """ + + # Fail. Terraform not found on system. + mocker.patch("shutil.which", return_value=None) + with pytest.raises(SystemExit): + crm._get_tf_path() + + fake_proc_1 = _FakeProc(0, stdout="v0.0", fake_stream=False) + fake_proc_2 = _FakeProc(0, stdout="v99.99", fake_stream=False) + + # Fail. Old version of terraform found. + mocker.patch("shutil.which", return_value="/opt/homebrew/bin/terraform") + mocker.patch("subprocess.run", return_value=fake_proc_1) + with pytest.raises(SystemExit): + crm._get_tf_path() + + # Succeed. + mocker.patch("subprocess.run", return_value=fake_proc_2) + mocker.patch("covalent.cloud_resource_manager.core.logger.error") + assert "terraform" in crm._get_tf_path()