Skip to content

Commit de8f5af

Browse files
k9ertmoneymanolis
andauthored
Feature: several things for Spectrum preparation (#1913)
* change Node persistence format * Removing the Singleton * mainly refactoring * intermediate commit * fix test * Updated diagram * fix tests * removing simplejsons references * fix tests * remove update, tests working * abstract Node * refining Migration framework: run migrations until they suceed * BrokenCoreConnectionException added to handle lost RPC + some minor error handling fixes * clicking redirects to node config if there is no node connection * AbstractNode * BrokenCoreConnectionException added to handle lost RPC + some minor error handling fixes * clicking redirects to node config if there is no node connection * handling broken connection when there is no other error superseding (i.e. no wallets) * Abstract Node improvements and issue fixing * improving node_info handling * fix test results, property device_manager in user, bcce in get_rpc in node, improved rpc property in node * typos * provide data_folders for extensions * parametrizing include for node_info * Remove initial_node_contribution refactoring welcome * adding convenience methods * extension data-storage * Tests green * Better failure resistence * Enabling Node settings * error handling * adjust_view_model callback * exception Handling * Shielding core from extension flaws * More consistent logging * Refactoring wallets endpoints * Make wallet_overview extendable * butgifex and cleanup * feedback by Manolis * rename BusinessObject to PersistentObject * fix Error-management and rollback information * Nodes.md corrections * frontend-aspects.md (small) corrections * fix tests and address feedback and errormanagement * fix tests, proper error-handling * Fix test_util_reflection * just some little addons to test_util_reflection * fix cleanup_on_exit * migration fix * remove check, add redirect after saving node * fix test (not that cool but what to do?) * upgrade Flask as Flask-SQLAlchemy needs higher Flask version * Revert "upgrade Flask as Flask-SQLAlchemy needs higher Flask version" This reverts commit bf24120. * update bug fixed + node manager added to node test * cleanup external_node Co-authored-by: moneymanolis <[email protected]>
1 parent 9a3f92d commit de8f5af

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+1102
-475
lines changed

docs/extensions/data-storage.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,22 @@ It is up to each `Service` implementation to decide what data is stored; the `Se
1515
This is also where `Service`-wide configuration or other information should be stored, _**even if it is not secret**_ (see above intro about not polluting other existing data stores).
1616

1717
## ServiceEncryptedStorageManager
18-
Because the `ServiceEncryptedStorage` is specific to each individual user, this manager provides convenient access to automatically retrieve the `current_user` from the Flask context and provide the correct user's `ServiceEncryptedStorage`. It is implemented as a `Singleton` which can be retrieved simply by importing the class and calling `get_instance()`.
18+
Because the `ServiceEncryptedStorage` is specific to each individual user, this manager provides convenient access to automatically retrieve the `current_user` from the Flask context and provide the correct user's `ServiceEncryptedStorage`.
1919

2020
This simplifies code to just asking for:
2121
```python
2222
from .service_encrypted_storage import ServiceEncryptedStorageManager
2323

24-
ServiceEncryptedStorageManager.get_instance().get_current_user_service_data(service_id=some_service_id)
24+
app.specter.service_encrypted_storage_manager.get_current_user_service_data(service_id=some_service_id)
2525
```
2626

2727
As a further convenience, the `Service` base class itself encapsulates `Service`-aware access to this per-`User` encrypted storage:
2828
```python
2929
@classmethod
3030
def get_current_user_service_data(cls) -> dict:
31-
return ServiceEncryptedStorageManager.get_instance().get_current_user_service_data(service_id=cls.id)
31+
return app.specter.service_encrypted_storage_manager.get_current_user_service_data(service_id=cls.id)
3232
```
3333

34-
Whenever possible, external code should not directly access these `Service`-related support classes but rather should ask for them through the `Service` class.
35-
3634
## `ServiceUnencryptedStorage`
3735
A disadvantage of the `ServiceEncryptedStorage` is, that the user needs to be freshly logged in in order to be able to decrypt the secrets. If you want to avoid that login but your extension should still store data on disk, you can use the `ServiceUnencryptedStorage`.
3836

@@ -49,7 +47,7 @@ _Note: current `Service` implementations have not yet needed this feature so dis
4947

5048
Unfortunately, the two unencrypted classes are derived from the encrypted one rather than having it the other way around or having abstract classes. This makes the diagram maybe a bit confusing.
5149

52-
[![](https://mermaid.ink/img/pako:eNqVVMFuwjAM_ZUqJzaVw66IIU0D7bRd0G6VItO4LFvqoCRlqhj_PpeWASLduhyiqH7v-dlOsxO5VSgmIjfg_VzD2kGZUcKr3Z-Q0Ol8DgGegWCNLpl-jcfJEt1W57ig3NWbgGoZrONoS-oJXjBfCaPcPxI-ENkAQVvyF7RHS4VeVw5WBpea1gaDpZbZZ6eT_9VyzMK18_8oZeIuE8l4fMsn4tOQRvZm7FPra24XZgLjK4--38BFTe1-uCORAe3acLOk1KSDlCOPpkgTxSBZWKPQpUlniUcnP7C-f7GENycmQYnSFvLdc7zQBk-hn1r4OxrlTxFjQY3ORKSHbUfcn3vuqTFm_MIyt4h3pX1zraTCA_0sX0b9ya5varxPB7DUKk0-wfC1HSh_PeJdFB7_Mc6sTKf--Hk2G96769lHhJrlW7xc1bJp538qGpQjJnVqhUhFia4ErfiROwhlIrxhiZmY8FFhAZUJmciogVYbHj8ulOb8YlKA8ZgKqIJd1pSLSXAVHkHdW9mh9t9YNMxZ)](https://mermaid-js.github.io/mermaid-live-editor/edit#pako:eNqVVMFuwjAM_ZUqJzaVw66IIU0D7bRd0G6VItO4LFvqoCRlqhj_PpeWASLduhyiqH7v-dlOsxO5VSgmIjfg_VzD2kGZUcKr3Z-Q0Ol8DgGegWCNLpl-jcfJEt1W57ig3NWbgGoZrONoS-oJXjBfCaPcPxI-ENkAQVvyF7RHS4VeVw5WBpea1gaDpZbZZ6eT_9VyzMK18_8oZeIuE8l4fMsn4tOQRvZm7FPra24XZgLjK4--38BFTe1-uCORAe3acLOk1KSDlCOPpkgTxSBZWKPQpUlniUcnP7C-f7GENycmQYnSFvLdc7zQBk-hn1r4OxrlTxFjQY3ORKSHbUfcn3vuqTFm_MIyt4h3pX1zraTCA_0sX0b9ya5varxPB7DUKk0-wfC1HSh_PeJdFB7_Mc6sTKf--Hk2G96769lHhJrlW7xc1bJp538qGpQjJnVqhUhFia4ErfiROwhlIrxhiZmY8FFhAZUJmciogVYbHj8ulOb8YlKA8ZgKqIJd1pSLSXAVHkHdW9mh9t9YNMxZ)
50+
[![](https://mermaid.ink/img/pako:eNqVVD1PwzAQ_SuRp4KSgTWCAakVEywVC4pkXeNLMTjnynaKotL_jpukTaLGEDxYdt579-4j8oHlWiBLWa7A2qWErYEyo8ivdn9CQiPzJTh4BoItmuj-O0miNZq9zHFFual3DsXaaePRVhQAR8pXwkntH4aPRNqBk5rsHMupENfOHWtWpIzdZSxKklt_In-a04igYyhaqDkd7AWeX1m04QRGNbV7M-OJBh9a-LQ4lyQd5wuLqogj4Um80EqgiaMuJd96_on1w4smvOmVBCVyXfAP6_FCKuyhSy3-Oyphe0RpEItBEG5h3wmPw5wDNU4lPkrZt8jvQlrYKOQCG_nAL6Ow2fWfNt2nhsyliKMvUArnhr8e8eE3emC8g5RsC_BNzU9l_8d5HCys7DNkMSvRlCCFfzsaXcbcO5aYsdQfBZjPjGV04lU7PxJcCem9WFqAshgzqJxe15Sz1JkKz6Tu_bmwdkBvWp_vxx_pzJiR)](https://mermaid-js.github.io/mermaid-live-editor/edit#pako:eNqVVD1PwzAQ_SuRp4KSgTWCAakVEywVC4pkXeNLMTjnynaKotL_jpukTaLGEDxYdt579-4j8oHlWiBLWa7A2qWErYEyo8ivdn9CQiPzJTh4BoItmuj-O0miNZq9zHFFual3DsXaaePRVhQAR8pXwkntH4aPRNqBk5rsHMupENfOHWtWpIzdZSxKklt_In-a04igYyhaqDkd7AWeX1m04QRGNbV7M-OJBh9a-LQ4lyQd5wuLqogj4Um80EqgiaMuJd96_on1w4smvOmVBCVyXfAP6_FCKuyhSy3-Oyphe0RpEItBEG5h3wmPw5wDNU4lPkrZt8jvQlrYKOQCG_nAL6Ow2fWfNt2nhsyliKMvUArnhr8e8eE3emC8g5RsC_BNzU9l_8d5HCys7DNkMSvRlCCFfzsaXcbcO5aYsdQfBZjPjGV04lU7PxJcCem9WFqAshgzqJxe15Sz1JkKz6Tu_bmwdkBvWp_vxx_pzJiR)
5351

5452
## Implementation Notes
5553
Efforts has been taken to provide `Service` data storage that is separate from existing data stores in order to keep those areas clean and simple. Where touchpoints are unavoidable, they are kept to the absolute bare minimum (e.g. `User.services` list in `users.json`, `Address.service_id` field).

docs/extensions/frontend-aspects.md

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ In your controller, the endpoint needs to be specified like this:
4141
ui = RubberduckService.blueprints["ui"]
4242
```
4343

44-
## Templates and Static Resources
44+
## Templates and static resources
4545

4646
The minimal url routes for `Service` selection and management. As usualy in Flask, `templates` and `static` resources are in their respective subfolders. Please note that there is an additional directory with the id of the extension which looks redundant at first. This is due to the way blueprints are loading templates and ensures that there are no naming collisions. Maybe at a later stage, this can be used to let plugins override other plugin's templates.
4747

@@ -66,8 +66,8 @@ You might have an extension which wants to inject e.g. JavaScript code into each
6666

6767
For this to work, the extension needs to be activated for the user, though.
6868

69-
### Extending dialogs
70-
You can extend the settings dialog or the wallet-dialog with your own templates. To do that, create a callback method in your service like:
69+
## Extending dialogs
70+
You can extend the settings dialog or the wallet dialog with your own templates. To do that, create a callback method in your service like:
7171

7272
```python
7373
from cryptoadvance.specter.services import callbacks
@@ -120,7 +120,7 @@ The `some_settingspage.jinja` should probably look exactly like all the other se
120120
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
121121
<h1 id="title" class="settings-title">Settings</h1>
122122
{% from 'settings/components/settings_menu.jinja' import settings_menu %}
123-
{{ settings_menu('myext_something', current_user, setting_exts) }}
123+
{{ settings_menu('myext_something', current_user, ext_settingstabs) }}
124124
<div class="card" style="margin: 20px auto;">
125125
<h1>{{ _("Something something") }} </h1>
126126
</div>
@@ -146,3 +146,26 @@ A reasonable `mywalletdetails.jinja` would look like this:
146146

147147
![](./images/extensions/add_wallettabs.png)
148148

149+
## Extending certain pages or complete endpoints
150+
151+
For some endpoints, there is the possibility to extend/change parts of a page or the complete page. This works by declaring the `callback_adjust_view_model` method in your extension and modify the ViewModel which got passed into the callback. As there is only one callback for all types of ViewModels, you will need to check for the type that you're expecting and only adjust this type. Here is an example:
152+
153+
```python
154+
from cryptoadvance.specter.server_endpoints.welcome.welcome_vm import WelcomeVm
155+
156+
class ExtensionidService(Service):
157+
[...}
158+
def callback_adjust_view_model(self, view_model: WelcomeVm):
159+
if type(view_model) == WelcomeVm:
160+
# potentially, we could make a redirect here:
161+
# view_model.about_redirect=url_for("spectrum_endpoint.some_enpoint_here")
162+
# but we do it small here and only replace a specific component:
163+
view_model.get_started_include = "spectrum/welcome/components/get_started.jinja"
164+
return view_model
165+
```
166+
167+
In this example, a certain part of the page gets replaced. As you can read in the comments, you could also trigger a complete redirect to a different endpoint.
168+
169+
Currently, only two `ViewModels` are existing. Check them out. Don't hesitate to create an issue if you'd like to modify something where no ViewModel exists yet:
170+
- cryptoadvance.specter.server_endpoints.welcome.welcome_vm
171+
- cryptoadvance.specter.server_endpoints.wallets.wallets_vm

docs/extensions/nodes.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Adding Nodes or NodeTypes
2+
3+
The whole programming-model is based on the Bitcoin-Core API. So we need Bitcoin Core nodes or elements nodes or at least something which behaves like that. So for the Spectrum Integration, we made extending the node possible. This is a short description of how that has been done and which extensionpoints might be helpfull here.
4+
5+
So to create your own Node, derive from `AbstractNode`:
6+
7+
```
8+
from cryptoadvance.specter.node import AbstractNode
9+
10+
class MyNode(AbstractNode):
11+
# [...]
12+
@classmethod
13+
def from_json(cls, node_dict, *args, **kwargs):
14+
[...]
15+
16+
def node_info_template(self):
17+
return "spectrum/components/spectrum_info.jinja"
18+
```
19+
20+
That class will need its own `fromJson` method. Overwrite the `node_info_template` method to specify your own template for the info-page which comes up if you click on a fully configured and functional node in the upper left corner. In order to smuggle your node into existence, you could potentially use the `callback_after_serverpy_init_app` callback. Have a look how the spectrum-extension did it [here](https://github.com/cryptoadvance/spectrum/pull/9/files#diff-82be7977bfa33bdbb0a448c7a03b43de90c4749565bef6737d6d516956ff0823R51-R62). Alternatively, you could create your own frontend in your controller and maybe additionally adjust the `WelcomeVm` model class as described in the frontend section.
21+
22+
If the `node_settings` are clicked for that node, we also expect that you have a `node_settings` endpoint in your controller. Otherwise there will be errors. Something like:
23+
24+
```
25+
@yourextension_endpoint.route("node/<node_alias>/", methods=["GET", "POST"])
26+
@login_required
27+
def node_settings(node_alias=None):
28+
[...]
29+
return render_template(...
30+
```

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ nav:
4444
- extensions/data-storage.md
4545
- extensions/callbacks.md
4646
- extensions/devices.md
47+
- extensions/nodes.md
4748

4849
theme:
4950
name: readthedocs

src/cryptoadvance/specter/helpers.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,20 @@ def alias(name):
121121
return "".join(x for x in name if x.isalnum() or x == "_").lower()
122122

123123

124+
def fullpath(data_folder, name):
125+
"""Quick way to get a fullpath which usually"""
126+
return os.path.join(data_folder, f"{alias(name)}.json")
127+
128+
129+
def calc_fullpath(data_folder, name):
130+
"""Get a fullpath for a Businessobject with a name quickly"""
131+
return os.path.join(data_folder, f"{alias(name)}.json")
132+
133+
124134
def deep_update(d, u):
125-
"""updates the dict d with the dict u"""
135+
"""updates the dict d with the dict u
136+
in short: second argument wins
137+
"""
126138
for k, v in six.iteritems(u):
127139
dv = d.get(k, {})
128140
if not isinstance(dv, collections.abc.Mapping):
@@ -151,6 +163,9 @@ def load_jsons(folder, key=None):
151163
d["alias"] = fname[:-5]
152164
dd[d[key]] = d
153165
except Exception as e:
166+
logger.error(
167+
f"Exception while loading json file {os.path.join(folder, fname)}"
168+
)
154169
logger.exception(e)
155170
return dd
156171

src/cryptoadvance/specter/internal_node.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class InternalNode(Node):
2525
BROKEN = "Broken"
2626
DOWN = "Down"
2727
RUNNING = "Running"
28+
external_node = False
2829

2930
def __init__(
3031
self,
@@ -53,7 +54,6 @@ def __init__(
5354
port,
5455
host,
5556
protocol,
56-
False,
5757
fullpath,
5858
"BTC",
5959
manager,
@@ -92,7 +92,6 @@ def from_json(cls, node_dict, manager, default_alias="", default_fullpath=""):
9292
port = node_dict.get("port", None)
9393
host = node_dict.get("host", "localhost")
9494
protocol = node_dict.get("protocol", "http")
95-
external_node = node_dict.get("external_node", True)
9695
fullpath = node_dict.get("fullpath", default_fullpath)
9796
bitcoind_path = node_dict.get("bitcoind_path", "")
9897
bitcoind_network = node_dict.get("bitcoind_network", "main")

src/cryptoadvance/specter/managers/node_manager.py

Lines changed: 64 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1+
from gc import callbacks
12
import os
23
import logging
34
import secrets
45
import shutil
56

67
from ..rpc import get_default_datadir, RPC_PORTS
7-
from ..specter_error import SpecterError
8-
from ..persistence import write_node, delete_file
9-
from ..helpers import alias, load_jsons
8+
from ..specter_error import SpecterError, SpecterInternalException
9+
from ..persistence import PersistentObject, write_node, delete_file
10+
from ..helpers import alias, calc_fullpath, load_jsons
1011
from ..node import Node
1112
from ..internal_node import InternalNode
13+
from ..services import callbacks
1214
from ..util.bitcoind_setup_tasks import setup_bitcoind_thread
1315

1416
logger = logging.getLogger(__name__)
@@ -27,43 +29,45 @@ def __init__(
2729
internal_bitcoind_version="",
2830
data_folder="",
2931
):
32+
self.nodes = {}
3033
self.data_folder = data_folder
3134
self._active_node = active_node
3235
self.proxy_url = proxy_url
3336
self.only_tor = only_tor
3437
self.bitcoind_path = bitcoind_path
3538
self.internal_bitcoind_version = internal_bitcoind_version
36-
self.update(data_folder)
39+
self.load_from_disk(data_folder)
3740
internal_nodes = [
3841
node for node in self.nodes.values() if not node.external_node
3942
]
4043
for node in internal_nodes:
4144
node.start()
4245

43-
def update(self, data_folder=None):
46+
def load_from_disk(self, data_folder=None):
4447
if data_folder is not None:
4548
self.data_folder = data_folder
4649
if data_folder.startswith("~"):
4750
data_folder = os.path.expanduser(data_folder)
4851
# creating folders if they don't exist
4952
if not os.path.isdir(data_folder):
5053
os.mkdir(data_folder)
51-
nodes = {}
5254
nodes_files = load_jsons(self.data_folder, key="name")
5355
for node_alias in nodes_files:
54-
fullpath = os.path.join(self.data_folder, "%s.json" % node_alias)
55-
node_class = (
56-
Node if nodes_files[node_alias].get("external_node") else InternalNode
57-
)
58-
nodes[nodes_files[node_alias]["name"]] = node_class.from_json(
59-
nodes_files[node_alias],
60-
self,
61-
default_alias=node_alias,
62-
default_fullpath=fullpath,
63-
)
64-
if not nodes:
56+
try:
57+
self.nodes[
58+
nodes_files[node_alias]["name"]
59+
] = PersistentObject.from_json(
60+
nodes_files[node_alias],
61+
self,
62+
default_alias=node_alias,
63+
default_fullpath=calc_fullpath(self.data_folder, node_alias),
64+
)
65+
except SpecterInternalException as e:
66+
logger.error(f"Skipping node {node_alias} due to {e}")
67+
68+
if not self.nodes:
6569
if os.environ.get("ELM_RPC_USER"):
66-
self.add_node(
70+
self.add_external_node(
6771
node_type="ELM",
6872
name="Blockstream Liquid",
6973
autodetect=True,
@@ -73,10 +77,10 @@ def update(self, data_folder=None):
7377
port=7041,
7478
host="localhost",
7579
protocol="http",
76-
external_node=True,
7780
default_alias=self.DEFAULT_ALIAS,
7881
)
79-
self.add_node(
82+
logger.info("Creating initial node-configuration")
83+
self.add_external_node(
8084
node_type="BTC",
8185
name="Bitcoin Core",
8286
autodetect=True,
@@ -86,11 +90,28 @@ def update(self, data_folder=None):
8690
port=8332,
8791
host="localhost",
8892
protocol="http",
89-
external_node=True,
9093
default_alias=self.DEFAULT_ALIAS,
9194
)
92-
else:
93-
self.nodes = nodes
95+
96+
# Just to be sure here ....
97+
has_default_node = False
98+
for name, node in self.nodes.items():
99+
if node.alias == self.DEFAULT_ALIAS:
100+
return
101+
# Make sure we always have a default node
102+
# (needed for the rpc-as-pin-authentication, created and used for raspiblitz)
103+
self.add_external_node(
104+
node_type="BTC",
105+
name="Bitcoin Core",
106+
autodetect=True,
107+
datadir=get_default_datadir(),
108+
user="",
109+
password="",
110+
port=8332,
111+
host="localhost",
112+
protocol="http",
113+
default_alias=self.DEFAULT_ALIAS,
114+
)
94115

95116
@property
96117
def active_node(self):
@@ -131,7 +152,7 @@ def update_bitcoind_version(self, specter, version):
131152
for node_alias in stopped_nodes:
132153
self.get_by_alias(node_alias).start(timeout=60)
133154

134-
def add_node(
155+
def add_external_node(
135156
self,
136157
node_type,
137158
name,
@@ -142,12 +163,12 @@ def add_node(
142163
port,
143164
host,
144165
protocol,
145-
external_node,
146166
default_alias=None,
147167
):
148168
"""Adding a node. Params:
149169
:param node_type: only valid for autodetect. Either BTC or ELM
150-
170+
This should only be used for an external node. Use add_internal_node for internal node
171+
and if you have defined your own node-type, use save_node directly. to save the node (and create it yourself)
151172
"""
152173
if not default_alias:
153174
node_alias = alias(name)
@@ -170,14 +191,22 @@ def add_node(
170191
port,
171192
host,
172193
protocol,
173-
external_node,
174194
fullpath,
175195
node_type,
176196
self,
177197
)
178-
logger.info(f"persisting {node} in add_node")
198+
logger.info(f"persisting {node} in add_external_node")
199+
self.nodes[name] = node
200+
return self.save_node(node)
201+
202+
def save_node(self, node):
203+
fullpath = (
204+
node.fullpath
205+
if hasattr(node, "fullpath")
206+
else calc_fullpath(self.data_folder, node.name)
207+
)
179208
write_node(node, fullpath)
180-
self.update() # reload files
209+
181210
logger.info("Added new node {}".format(node.alias))
182211
return node
183212

@@ -189,6 +218,10 @@ def add_internal_node(
189218
default_alias=None,
190219
datadir=None,
191220
):
221+
"""Adding an internal node. Params:
222+
This should only be used for internal nodes. Use add__External_node for external nodes
223+
and if you have defined your own node-type, use save_node directly. to save the node (and create it yourself)
224+
"""
192225
if not default_alias:
193226
node_alias = alias(name)
194227
else:
@@ -218,11 +251,8 @@ def add_internal_node(
218251
network,
219252
self.internal_bitcoind_version,
220253
)
221-
logger.info(f"persisting {node} in add_internal_node")
222-
write_node(node, fullpath)
223-
self.update() # reload files
224-
logger.info("Added new internal node {}".format(node.alias))
225-
return node
254+
self.nodes[name] = node
255+
return self.save_node(node)
226256

227257
def delete_node(self, node, specter):
228258
logger.info("Deleting {}".format(node.alias))
@@ -232,5 +262,4 @@ def delete_node(self, node, specter):
232262
if self._active_node == node.alias:
233263
specter.update_active_node(next(iter(self.nodes.values())).alias)
234264
del self.nodes[node.name]
235-
self.update()
236265
logger.info("Node {} was deleted successfully".format(node.alias))

0 commit comments

Comments
 (0)