This commit is contained in:
2023-04-14 11:44:56 -03:00
parent 68b63baeac
commit 51bdc4e59a
5 changed files with 447 additions and 156 deletions

View File

@ -60,6 +60,7 @@ Commands:
bulk Add nodes in bulk
run Run scripts or commands on nodes
config Manage app config
api Start and stop connpy api
</code></pre>
<h3 id="manage-profiles">Manage profiles</h3>
<pre><code>usage: conn profile [-h] (--add | --del | --mod | --show) profile
@ -85,6 +86,50 @@ options:
conn pc@office
conn server
</code></pre>
<h2 id="http-api">http API</h2>
<p>With the Connpy API you can run commands on devices using http requests</p>
<h3 id="1-list-nodes">1. List Nodes</h3>
<p><strong>Endpoint</strong>: <code>/list_nodes</code></p>
<p><strong>Method</strong>: <code>POST</code></p>
<p><strong>Description</strong>: This route returns a list of nodes. It can also filter the list based on a given keyword.</p>
<h4 id="request-body">Request Body:</h4>
<pre><code class="language-json">{
&quot;filter&quot;: &quot;&lt;keyword&gt;&quot;
}
</code></pre>
<ul>
<li><code>filter</code> (optional): A keyword to filter the list of nodes. It returns only the nodes that contain the keyword. If not provided, the route will return the entire list of nodes.</li>
</ul>
<h4 id="response">Response:</h4>
<ul>
<li>A JSON array containing the filtered list of nodes.</li>
</ul>
<hr>
<h3 id="2-run-commands">2. Run Commands</h3>
<p><strong>Endpoint</strong>: <code>/run_commands</code></p>
<p><strong>Method</strong>: <code>POST</code></p>
<p><strong>Description</strong>: This route runs commands on selected nodes based on the provided action, nodes, and commands. It also supports executing tests by providing expected results.</p>
<h4 id="request-body_1">Request Body:</h4>
<pre><code class="language-json">{
&quot;action&quot;: &quot;&lt;action&gt;&quot;,
&quot;nodes&quot;: &quot;&lt;nodes&gt;&quot;,
&quot;commands&quot;: &quot;&lt;commands&gt;&quot;,
&quot;expected&quot;: &quot;&lt;expected&gt;&quot;,
&quot;options&quot;: &quot;&lt;options&gt;&quot;
}
</code></pre>
<ul>
<li><code>action</code> (required): The action to be performed. Possible values: <code>run</code> or <code>test</code>.</li>
<li><code><a title="connpy.nodes" href="#connpy.nodes">nodes</a></code> (required): A list of nodes or a single node on which the commands will be executed. The nodes can be specified as individual node names or a node group with the <code>@</code> prefix. Node groups can also be specified as arrays with a list of nodes inside the group.</li>
<li><code>commands</code> (required): A list of commands to be executed on the specified nodes.</li>
<li><code>expected</code> (optional, only used when the action is <code>test</code>): A single expected result for the test.</li>
<li><code>options</code> (optional): Array to pass options to the run command, options are: <code>prompt</code>, <code>parallel</code>, <code>timeout</code>
</li>
</ul>
<h4 id="response_1">Response:</h4>
<ul>
<li>A JSON object with the results of the executed commands on the nodes.</li>
</ul>
<h2 id="automation-module">Automation module</h2>
<p>the automation module</p>
<h3 id="standalone-module">Standalone module</h3>
@ -200,6 +245,7 @@ Commands:
bulk Add nodes in bulk
run Run scripts or commands on nodes
config Manage app config
api Start and stop connpy api
```
### Manage profiles
@ -229,7 +275,62 @@ options:
conn pc@office
conn server
```
## http API
With the Connpy API you can run commands on devices using http requests
### 1. List Nodes
**Endpoint**: `/list_nodes`
**Method**: `POST`
**Description**: This route returns a list of nodes. It can also filter the list based on a given keyword.
#### Request Body:
```json
{
&#34;filter&#34;: &#34;&lt;keyword&gt;&#34;
}
```
* `filter` (optional): A keyword to filter the list of nodes. It returns only the nodes that contain the keyword. If not provided, the route will return the entire list of nodes.
#### Response:
- A JSON array containing the filtered list of nodes.
---
### 2. Run Commands
**Endpoint**: `/run_commands`
**Method**: `POST`
**Description**: This route runs commands on selected nodes based on the provided action, nodes, and commands. It also supports executing tests by providing expected results.
#### Request Body:
```json
{
&#34;action&#34;: &#34;&lt;action&gt;&#34;,
&#34;nodes&#34;: &#34;&lt;nodes&gt;&#34;,
&#34;commands&#34;: &#34;&lt;commands&gt;&#34;,
&#34;expected&#34;: &#34;&lt;expected&gt;&#34;,
&#34;options&#34;: &#34;&lt;options&gt;&#34;
}
```
* `action` (required): The action to be performed. Possible values: `run` or `test`.
* `nodes` (required): A list of nodes or a single node on which the commands will be executed. The nodes can be specified as individual node names or a node group with the `@` prefix. Node groups can also be specified as arrays with a list of nodes inside the group.
* `commands` (required): A list of commands to be executed on the specified nodes.
* `expected` (optional, only used when the action is `test`): A single expected result for the test.
* `options` (optional): Array to pass options to the run command, options are: `prompt`, `parallel`, `timeout`
#### Response:
- A JSON object with the results of the executed commands on the nodes.
## Automation module
the automation module
@ -316,6 +417,7 @@ __author__ = &#34;Federico Luzzi&#34;
__pdoc__ = {
&#39;core&#39;: False,
&#39;completion&#39;: False,
&#39;api&#39;: False
}</code></pre>
</details>
</section>
@ -404,9 +506,17 @@ __pdoc__ = {
&#39;&#39;&#39;
home = os.path.expanduser(&#34;~&#34;)
defaultdir = home + &#39;/.config/conn&#39;
defaultfile = defaultdir + &#39;/config.json&#39;
defaultkey = defaultdir + &#39;/.osk&#39;
Path(defaultdir).mkdir(parents=True, exist_ok=True)
pathfile = defaultdir + &#39;/.folder&#39;
try:
with open(pathfile, &#34;r&#34;) as f:
configdir = f.read().strip()
except:
with open(pathfile, &#34;w&#34;) as f:
f.write(str(defaultdir))
configdir = defaultdir
defaultfile = configdir + &#39;/config.json&#39;
defaultkey = configdir + &#39;/.osk&#39;
if conf == None:
self.file = defaultfile
else:
@ -587,7 +697,47 @@ __pdoc__ = {
def _profiles_del(self,*, id ):
#Delete profile from config
del self.profiles[id]</code></pre>
del self.profiles[id]
def _getallnodes(self):
#get all nodes on configfile
nodes = []
layer1 = [k for k,v in self.connections.items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;connection&#34;]
folders = [k for k,v in self.connections.items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;folder&#34;]
nodes.extend(layer1)
for f in folders:
layer2 = [k + &#34;@&#34; + f for k,v in self.connections[f].items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;connection&#34;]
nodes.extend(layer2)
subfolders = [k for k,v in self.connections[f].items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;subfolder&#34;]
for s in subfolders:
layer3 = [k + &#34;@&#34; + s + &#34;@&#34; + f for k,v in self.connections[f][s].items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;connection&#34;]
nodes.extend(layer3)
return nodes
def _getallfolders(self):
#get all folders on configfile
folders = [&#34;@&#34; + k for k,v in self.connections.items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;folder&#34;]
subfolders = []
for f in folders:
s = [&#34;@&#34; + k + f for k,v in self.connections[f[1:]].items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;subfolder&#34;]
subfolders.extend(s)
folders.extend(subfolders)
return folders
def _profileused(self, profile):
#Check if profile is used before deleting it
nodes = []
layer1 = [k for k,v in self.connections.items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;connection&#34; and (&#34;@&#34; + profile in v.values() or ( isinstance(v[&#34;password&#34;],list) and &#34;@&#34; + profile in v[&#34;password&#34;]))]
folders = [k for k,v in self.connections.items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;folder&#34;]
nodes.extend(layer1)
for f in folders:
layer2 = [k + &#34;@&#34; + f for k,v in self.connections[f].items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;connection&#34; and (&#34;@&#34; + profile in v.values() or ( isinstance(v[&#34;password&#34;],list) and &#34;@&#34; + profile in v[&#34;password&#34;]))]
nodes.extend(layer2)
subfolders = [k for k,v in self.connections[f].items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;subfolder&#34;]
for s in subfolders:
layer3 = [k + &#34;@&#34; + s + &#34;@&#34; + f for k,v in self.connections[f][s].items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;connection&#34; and (&#34;@&#34; + profile in v.values() or ( isinstance(v[&#34;password&#34;],list) and &#34;@&#34; + profile in v[&#34;password&#34;]))]
nodes.extend(layer3)
return nodes</code></pre>
</details>
<h3>Methods</h3>
<dl>
@ -703,8 +853,8 @@ __pdoc__ = {
self.node = node
self.connnodes = nodes
self.config = config
self.nodes = self._getallnodes()
self.folders = self._getallfolders()
self.nodes = self.config._getallnodes()
self.folders = self.config._getallfolders()
self.profiles = list(self.config.profiles.keys())
self.case = self.config.config[&#34;case&#34;]
try:
@ -766,6 +916,13 @@ __pdoc__ = {
runparser.add_argument(&#34;run&#34;, nargs=&#39;+&#39;, action=self._store_type, help=self._help(&#34;run&#34;), default=&#34;run&#34;)
runparser.add_argument(&#34;-g&#34;,&#34;--generate&#34;, dest=&#34;action&#34;, action=&#34;store_const&#34;, help=&#34;Generate yaml file template&#34;, const=&#34;generate&#34;, default=&#34;run&#34;)
runparser.set_defaults(func=self._func_run)
#APIPARSER
apiparser = subparsers.add_parser(&#34;api&#34;, help=&#34;Start and stop connpy api&#34;)
apicrud = apiparser.add_mutually_exclusive_group(required=True)
apicrud.add_argument(&#34;--start&#34;, dest=&#34;start&#34;, nargs=0, action=self._store_type, help=&#34;Start conppy api&#34;)
apicrud.add_argument(&#34;--restart&#34;, dest=&#34;restart&#34;, nargs=0, action=self._store_type, help=&#34;Restart conppy api&#34;)
apicrud.add_argument(&#34;--stop&#34;, dest=&#34;stop&#34;, nargs=0, action=self._store_type, help=&#34;Stop conppy api&#34;)
apiparser.set_defaults(func=self._func_api)
#CONFIGPARSER
configparser = subparsers.add_parser(&#34;config&#34;, help=&#34;Manage app config&#34;)
configcrud = configparser.add_mutually_exclusive_group(required=True)
@ -773,9 +930,10 @@ __pdoc__ = {
configcrud.add_argument(&#34;--fzf&#34;, dest=&#34;fzf&#34;, nargs=1, action=self._store_type, help=&#34;Use fzf for lists&#34;, choices=[&#34;true&#34;,&#34;false&#34;])
configcrud.add_argument(&#34;--keepalive&#34;, dest=&#34;idletime&#34;, nargs=1, action=self._store_type, help=&#34;Set keepalive time in seconds, 0 to disable&#34;, type=int, metavar=&#34;INT&#34;)
configcrud.add_argument(&#34;--completion&#34;, dest=&#34;completion&#34;, nargs=1, choices=[&#34;bash&#34;,&#34;zsh&#34;], action=self._store_type, help=&#34;Get terminal completion configuration for conn&#34;)
configcrud.add_argument(&#34;--configfolder&#34;, dest=&#34;configfolder&#34;, nargs=1, action=self._store_type, help=&#34;Set the default location for config file&#34;, metavar=&#34;FOLDER&#34;)
configparser.set_defaults(func=self._func_others)
#Manage sys arguments
commands = [&#34;node&#34;, &#34;profile&#34;, &#34;mv&#34;, &#34;move&#34;,&#34;copy&#34;, &#34;cp&#34;, &#34;bulk&#34;, &#34;ls&#34;, &#34;list&#34;, &#34;run&#34;, &#34;config&#34;]
commands = [&#34;node&#34;, &#34;profile&#34;, &#34;mv&#34;, &#34;move&#34;,&#34;copy&#34;, &#34;cp&#34;, &#34;bulk&#34;, &#34;ls&#34;, &#34;list&#34;, &#34;run&#34;, &#34;config&#34;, &#34;api&#34;]
profilecmds = [&#34;--add&#34;, &#34;-a&#34;, &#34;--del&#34;, &#34;--rm&#34;, &#34;-r&#34;, &#34;--mod&#34;, &#34;--edit&#34;, &#34;-e&#34;, &#34;--show&#34;, &#34;-s&#34;]
if len(argv) &gt;= 2 and argv[1] == &#34;profile&#34; and argv[0] in profilecmds:
argv[1] = argv[0]
@ -964,7 +1122,7 @@ __pdoc__ = {
if matches[0] == &#34;default&#34;:
print(&#34;Can&#39;t delete default profile&#34;)
exit(6)
usedprofile = self._profileused(matches[0])
usedprofile = self.config._profileused(matches[0])
if len(usedprofile) &gt; 0:
print(&#34;Profile {} used in the following nodes:&#34;.format(matches[0]))
print(&#34;, &#34;.join(usedprofile))
@ -1026,7 +1184,7 @@ __pdoc__ = {
def _func_others(self, args):
#Function called when using other commands
actions = {&#34;ls&#34;: self._ls, &#34;move&#34;: self._mvcp, &#34;cp&#34;: self._mvcp, &#34;bulk&#34;: self._bulk, &#34;completion&#34;: self._completion, &#34;case&#34;: self._case, &#34;fzf&#34;: self._fzf, &#34;idletime&#34;: self._idletime}
actions = {&#34;ls&#34;: self._ls, &#34;move&#34;: self._mvcp, &#34;cp&#34;: self._mvcp, &#34;bulk&#34;: self._bulk, &#34;completion&#34;: self._completion, &#34;case&#34;: self._case, &#34;fzf&#34;: self._fzf, &#34;idletime&#34;: self._idletime, &#34;configfolder&#34;: self._configfolder}
return actions.get(args.command)(args)
def _ls(self, args):
@ -1100,7 +1258,7 @@ __pdoc__ = {
newnode[&#34;password&#34;] = newnodes[&#34;password&#34;]
count +=1
self.config._connections_add(**newnode)
self.nodes = self._getallnodes()
self.nodes = self.config._getallnodes()
if count &gt; 0:
self.config._saveconfig(self.config.file)
print(&#34;Succesfully added {} nodes&#34;.format(count))
@ -1132,6 +1290,16 @@ __pdoc__ = {
args.data[0] = 0
self._change_settings(args.command, args.data[0])
def _configfolder(self, args):
if not os.path.isdir(args.data[0]):
raise argparse.ArgumentTypeError(f&#34;readable_dir:{args.data[0]} is not a valid path&#34;)
else:
pathfile = defaultdir + &#34;/.folder&#34;
folder = os.path.abspath(args.data[0]).rstrip(&#39;/&#39;)
with open(pathfile, &#34;w&#34;) as f:
f.write(str(folder))
print(&#34;Config saved&#34;)
def _change_settings(self, name, value):
self.config.config[name] = value
self.config._saveconfig(self.config.file)
@ -1143,6 +1311,13 @@ __pdoc__ = {
actions = {&#34;noderun&#34;: self._node_run, &#34;generate&#34;: self._yaml_generate, &#34;run&#34;: self._yaml_run}
return actions.get(args.action)(args)
def _func_api(self, args):
if args.command == &#34;stop&#34; or args.command == &#34;restart&#34;:
stop_api()
if args.command == &#34;start&#34; or args.command == &#34;restart&#34;:
start_api()
return
def _node_run(self, args):
command = &#34; &#34;.join(args.data[1:])
command = command.split(&#34;-&#34;)
@ -1533,7 +1708,7 @@ __pdoc__ = {
if type == &#34;usage&#34;:
return &#34;conn [-h] [--add | --del | --mod | --show | --debug] [node|folder]\n conn {profile,move,mv,copy,cp,list,ls,bulk,config} ...&#34;
if type == &#34;end&#34;:
return &#34;Commands:\n profile Manage profiles\n move (mv) Move node\n copy (cp) Copy node\n list (ls) List profiles, nodes or folders\n bulk Add nodes in bulk\n run Run scripts or commands on nodes\n config Manage app config&#34;
return &#34;Commands:\n profile Manage profiles\n move (mv) Move node\n copy (cp) Copy node\n list (ls) List profiles, nodes or folders\n bulk Add nodes in bulk\n run Run scripts or commands on nodes\n config Manage app config\n api Start and stop connpy api&#34;
if type == &#34;bashcompletion&#34;:
return &#39;&#39;&#39;
#Here starts bash completion for conn
@ -1633,46 +1808,6 @@ tasks:
output: null
...&#39;&#39;&#39;
def _getallnodes(self):
#get all nodes on configfile
nodes = []
layer1 = [k for k,v in self.config.connections.items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;connection&#34;]
folders = [k for k,v in self.config.connections.items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;folder&#34;]
nodes.extend(layer1)
for f in folders:
layer2 = [k + &#34;@&#34; + f for k,v in self.config.connections[f].items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;connection&#34;]
nodes.extend(layer2)
subfolders = [k for k,v in self.config.connections[f].items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;subfolder&#34;]
for s in subfolders:
layer3 = [k + &#34;@&#34; + s + &#34;@&#34; + f for k,v in self.config.connections[f][s].items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;connection&#34;]
nodes.extend(layer3)
return nodes
def _getallfolders(self):
#get all folders on configfile
folders = [&#34;@&#34; + k for k,v in self.config.connections.items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;folder&#34;]
subfolders = []
for f in folders:
s = [&#34;@&#34; + k + f for k,v in self.config.connections[f[1:]].items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;subfolder&#34;]
subfolders.extend(s)
folders.extend(subfolders)
return folders
def _profileused(self, profile):
#Check if profile is used before deleting it
nodes = []
layer1 = [k for k,v in self.config.connections.items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;connection&#34; and (&#34;@&#34; + profile in v.values() or ( isinstance(v[&#34;password&#34;],list) and &#34;@&#34; + profile in v[&#34;password&#34;]))]
folders = [k for k,v in self.config.connections.items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;folder&#34;]
nodes.extend(layer1)
for f in folders:
layer2 = [k + &#34;@&#34; + f for k,v in self.config.connections[f].items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;connection&#34; and (&#34;@&#34; + profile in v.values() or ( isinstance(v[&#34;password&#34;],list) and &#34;@&#34; + profile in v[&#34;password&#34;]))]
nodes.extend(layer2)
subfolders = [k for k,v in self.config.connections[f].items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;subfolder&#34;]
for s in subfolders:
layer3 = [k + &#34;@&#34; + s + &#34;@&#34; + f for k,v in self.config.connections[f][s].items() if isinstance(v, dict) and v[&#34;type&#34;] == &#34;connection&#34; and (&#34;@&#34; + profile in v.values() or ( isinstance(v[&#34;password&#34;],list) and &#34;@&#34; + profile in v[&#34;password&#34;]))]
nodes.extend(layer3)
return nodes
def encrypt(self, password, keyfile=None):
&#39;&#39;&#39;
Encrypts password using RSA keyfile
@ -1751,7 +1886,7 @@ tasks:
</details>
</dd>
<dt id="connpy.connapp.start"><code class="name flex">
<span>def <span class="ident">start</span></span>(<span>self, argv=['-f', '-o', 'docs/', '--html', 'connpy'])</span>
<span>def <span class="ident">start</span></span>(<span>self, argv=['--html', 'connpy', '-o', 'docs', '-f'])</span>
</code></dt>
<dd>
<div class="desc"><h3 id="parameters">Parameters:</h3>
@ -1815,6 +1950,13 @@ tasks:
runparser.add_argument(&#34;run&#34;, nargs=&#39;+&#39;, action=self._store_type, help=self._help(&#34;run&#34;), default=&#34;run&#34;)
runparser.add_argument(&#34;-g&#34;,&#34;--generate&#34;, dest=&#34;action&#34;, action=&#34;store_const&#34;, help=&#34;Generate yaml file template&#34;, const=&#34;generate&#34;, default=&#34;run&#34;)
runparser.set_defaults(func=self._func_run)
#APIPARSER
apiparser = subparsers.add_parser(&#34;api&#34;, help=&#34;Start and stop connpy api&#34;)
apicrud = apiparser.add_mutually_exclusive_group(required=True)
apicrud.add_argument(&#34;--start&#34;, dest=&#34;start&#34;, nargs=0, action=self._store_type, help=&#34;Start conppy api&#34;)
apicrud.add_argument(&#34;--restart&#34;, dest=&#34;restart&#34;, nargs=0, action=self._store_type, help=&#34;Restart conppy api&#34;)
apicrud.add_argument(&#34;--stop&#34;, dest=&#34;stop&#34;, nargs=0, action=self._store_type, help=&#34;Stop conppy api&#34;)
apiparser.set_defaults(func=self._func_api)
#CONFIGPARSER
configparser = subparsers.add_parser(&#34;config&#34;, help=&#34;Manage app config&#34;)
configcrud = configparser.add_mutually_exclusive_group(required=True)
@ -1822,9 +1964,10 @@ tasks:
configcrud.add_argument(&#34;--fzf&#34;, dest=&#34;fzf&#34;, nargs=1, action=self._store_type, help=&#34;Use fzf for lists&#34;, choices=[&#34;true&#34;,&#34;false&#34;])
configcrud.add_argument(&#34;--keepalive&#34;, dest=&#34;idletime&#34;, nargs=1, action=self._store_type, help=&#34;Set keepalive time in seconds, 0 to disable&#34;, type=int, metavar=&#34;INT&#34;)
configcrud.add_argument(&#34;--completion&#34;, dest=&#34;completion&#34;, nargs=1, choices=[&#34;bash&#34;,&#34;zsh&#34;], action=self._store_type, help=&#34;Get terminal completion configuration for conn&#34;)
configcrud.add_argument(&#34;--configfolder&#34;, dest=&#34;configfolder&#34;, nargs=1, action=self._store_type, help=&#34;Set the default location for config file&#34;, metavar=&#34;FOLDER&#34;)
configparser.set_defaults(func=self._func_others)
#Manage sys arguments
commands = [&#34;node&#34;, &#34;profile&#34;, &#34;mv&#34;, &#34;move&#34;,&#34;copy&#34;, &#34;cp&#34;, &#34;bulk&#34;, &#34;ls&#34;, &#34;list&#34;, &#34;run&#34;, &#34;config&#34;]
commands = [&#34;node&#34;, &#34;profile&#34;, &#34;mv&#34;, &#34;move&#34;,&#34;copy&#34;, &#34;cp&#34;, &#34;bulk&#34;, &#34;ls&#34;, &#34;list&#34;, &#34;run&#34;, &#34;config&#34;, &#34;api&#34;]
profilecmds = [&#34;--add&#34;, &#34;-a&#34;, &#34;--del&#34;, &#34;--rm&#34;, &#34;-r&#34;, &#34;--mod&#34;, &#34;--edit&#34;, &#34;-e&#34;, &#34;--show&#34;, &#34;-s&#34;]
if len(argv) &gt;= 2 and argv[1] == &#34;profile&#34; and argv[0] in profilecmds:
argv[1] = argv[0]
@ -3180,6 +3323,19 @@ tasks:
<li><a href="#examples">Examples</a></li>
</ul>
</li>
<li><a href="#http-api">http API</a><ul>
<li><a href="#1-list-nodes">1. List Nodes</a><ul>
<li><a href="#request-body">Request Body:</a></li>
<li><a href="#response">Response:</a></li>
</ul>
</li>
<li><a href="#2-run-commands">2. Run Commands</a><ul>
<li><a href="#request-body_1">Request Body:</a></li>
<li><a href="#response_1">Response:</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="#automation-module">Automation module</a><ul>
<li><a href="#standalone-module">Standalone module</a></li>
<li><a href="#using-manager-configuration">Using manager configuration</a></li>