devpy-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app.py +17 -0
- backend.py +417 -0
- config_manager.py +47 -0
- devpy_cli-1.0.0.dist-info/METADATA +330 -0
- devpy_cli-1.0.0.dist-info/RECORD +12 -0
- devpy_cli-1.0.0.dist-info/WHEEL +5 -0
- devpy_cli-1.0.0.dist-info/entry_points.txt +2 -0
- devpy_cli-1.0.0.dist-info/top_level.txt +7 -0
- frontend_cli.py +205 -0
- permissions_config_manager.py +92 -0
- permissions_manager.py +174 -0
- ssh_key_manager.py +85 -0
app.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
# Check for .env before importing frontend_cli which imports backend
|
|
4
|
+
if not os.path.exists('.env'):
|
|
5
|
+
try:
|
|
6
|
+
from setup_wizard import run_setup
|
|
7
|
+
|
|
8
|
+
run_setup()
|
|
9
|
+
except ImportError:
|
|
10
|
+
print('Error: setup_wizard module not found. Please ensure all files are installed correctly.')
|
|
11
|
+
exit(1)
|
|
12
|
+
|
|
13
|
+
from frontend_cli import run_cli
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == '__main__':
|
|
17
|
+
run_cli()
|
backend.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import threading
|
|
3
|
+
import psutil
|
|
4
|
+
import docker
|
|
5
|
+
import time
|
|
6
|
+
import tempfile
|
|
7
|
+
import atexit
|
|
8
|
+
import re
|
|
9
|
+
from docker.transport import SSHHTTPAdapter
|
|
10
|
+
from dotenv import load_dotenv
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.markdown import Markdown
|
|
13
|
+
from langchain_core.tools import tool
|
|
14
|
+
from langgraph.prebuilt import create_react_agent
|
|
15
|
+
from langgraph.checkpoint.memory import MemorySaver
|
|
16
|
+
from langchain_core.messages import HumanMessage
|
|
17
|
+
from permissions_manager import PermissionManager, PermissionDecision
|
|
18
|
+
from config_manager import ConfigManager
|
|
19
|
+
from ssh_key_manager import SSHKeyManager
|
|
20
|
+
|
|
21
|
+
load_dotenv()
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
config_manager = ConfigManager()
|
|
26
|
+
ssh_key_manager = SSHKeyManager()
|
|
27
|
+
_docker_client = None
|
|
28
|
+
_ssh_temp_key_path = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cleanup_temp_key():
|
|
32
|
+
global _ssh_temp_key_path
|
|
33
|
+
if _ssh_temp_key_path and os.path.exists(_ssh_temp_key_path):
|
|
34
|
+
try:
|
|
35
|
+
os.remove(_ssh_temp_key_path)
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
atexit.register(cleanup_temp_key)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CustomSSHAdapter(SSHHTTPAdapter):
|
|
44
|
+
def __init__(self, base_url, key_filename, **kwargs):
|
|
45
|
+
self.key_filename = key_filename
|
|
46
|
+
super().__init__(base_url, **kwargs)
|
|
47
|
+
|
|
48
|
+
def _create_paramiko_client(self, base_url):
|
|
49
|
+
super()._create_paramiko_client(base_url)
|
|
50
|
+
if self.key_filename:
|
|
51
|
+
self.ssh_params['key_filename'] = self.key_filename
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def reset_docker_client():
|
|
55
|
+
global _docker_client
|
|
56
|
+
if _docker_client:
|
|
57
|
+
try:
|
|
58
|
+
_docker_client.close()
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
_docker_client = None
|
|
62
|
+
cleanup_temp_key()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_docker_client():
|
|
66
|
+
global _docker_client, _ssh_temp_key_path
|
|
67
|
+
if _docker_client:
|
|
68
|
+
return _docker_client
|
|
69
|
+
|
|
70
|
+
mode = config_manager.get_mode()
|
|
71
|
+
if mode == 'local':
|
|
72
|
+
try:
|
|
73
|
+
_docker_client = docker.from_env()
|
|
74
|
+
except Exception as e:
|
|
75
|
+
console.print(f'[bold red]Error initializing local Docker client: {e}[/bold red]')
|
|
76
|
+
# Return a dummy client or let it fail later?
|
|
77
|
+
# Better to raise, but tools might need handling.
|
|
78
|
+
raise e
|
|
79
|
+
else:
|
|
80
|
+
ssh_config = config_manager.get_ssh_config()
|
|
81
|
+
host = ssh_config.get('host')
|
|
82
|
+
user = ssh_config.get('user')
|
|
83
|
+
key_name = ssh_config.get('key_name')
|
|
84
|
+
|
|
85
|
+
if not host or not user or not key_name:
|
|
86
|
+
console.print('[bold red]SSH configuration incomplete. Please configure SSH settings.[/bold red]')
|
|
87
|
+
raise ValueError('SSH configuration incomplete')
|
|
88
|
+
|
|
89
|
+
# Get Passphrase
|
|
90
|
+
passphrase = os.getenv('DOCKER_SSH_PASSPHRASE')
|
|
91
|
+
if not passphrase:
|
|
92
|
+
from rich.prompt import Prompt
|
|
93
|
+
|
|
94
|
+
passphrase = Prompt.ask(f"Enter passphrase for key '{key_name}'", password=True)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
private_key_content = ssh_key_manager.get_key(key_name, passphrase)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
console.print(f'[bold red]Failed to load SSH key: {e}[/bold red]')
|
|
100
|
+
raise e
|
|
101
|
+
|
|
102
|
+
# Create temp file for the key
|
|
103
|
+
# We need to close it so paramiko can open it by name
|
|
104
|
+
tf = tempfile.NamedTemporaryFile(delete=False, mode='w', encoding='utf-8')
|
|
105
|
+
tf.write(private_key_content)
|
|
106
|
+
tf.close()
|
|
107
|
+
_ssh_temp_key_path = tf.name
|
|
108
|
+
|
|
109
|
+
ssh_url = f'ssh://{user}@{host}'
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
# Create SSH Adapter
|
|
113
|
+
ssh_adapter = CustomSSHAdapter(ssh_url, key_filename=_ssh_temp_key_path)
|
|
114
|
+
|
|
115
|
+
# Create API Client
|
|
116
|
+
api_client = docker.APIClient(base_url='http://localhost', version='auto')
|
|
117
|
+
api_client.mount('http+docker://ssh', ssh_adapter)
|
|
118
|
+
api_client.base_url = 'http+docker://ssh'
|
|
119
|
+
|
|
120
|
+
# Create Docker Client
|
|
121
|
+
client = docker.DockerClient(version='auto')
|
|
122
|
+
client.api = api_client
|
|
123
|
+
_docker_client = client
|
|
124
|
+
except Exception as e:
|
|
125
|
+
cleanup_temp_key()
|
|
126
|
+
console.print(f'[bold red]Error connecting to remote Docker: {e}[/bold red]')
|
|
127
|
+
raise e
|
|
128
|
+
|
|
129
|
+
return _docker_client
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
global_config = {'configurable': {'thread_id': 'prinsipal_devops'}}
|
|
133
|
+
|
|
134
|
+
permission_manager = PermissionManager()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_command_preview(parts):
|
|
138
|
+
return ' '.join(str(p) for p in parts)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def permission_prompt(operation, impact, command_preview):
|
|
142
|
+
console.print('\n[bold yellow]Permission Required[/bold yellow]')
|
|
143
|
+
console.print(f'Operation: {operation}')
|
|
144
|
+
if impact:
|
|
145
|
+
console.print(f'Potential Impact: {impact}')
|
|
146
|
+
if command_preview:
|
|
147
|
+
console.print(f'Command: {command_preview}')
|
|
148
|
+
console.print('Options: (y) yes, (n) no, (yc) yes for command, (ys) yes for session')
|
|
149
|
+
from rich.prompt import Prompt
|
|
150
|
+
|
|
151
|
+
answer = Prompt.ask('(y/n/yc/ys)', choices=['y', 'n', 'yc', 'ys'], default='n')
|
|
152
|
+
if answer == 'y':
|
|
153
|
+
return PermissionDecision.ALLOW_ONCE
|
|
154
|
+
if answer == 'yc':
|
|
155
|
+
return PermissionDecision.ALLOW_COMMAND
|
|
156
|
+
if answer == 'ys':
|
|
157
|
+
return PermissionDecision.ALLOW_SESSION
|
|
158
|
+
return PermissionDecision.DENY
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@tool
|
|
162
|
+
def check_resource() -> str:
|
|
163
|
+
"""Shows system CPU, memory, and disk usage"""
|
|
164
|
+
# For remote docker, psutil runs on LOCAL machine.
|
|
165
|
+
# If we want remote stats, we should use a container or docker stats?
|
|
166
|
+
# The user asked for "Remote Docker execution".
|
|
167
|
+
# check_resource using psutil checks LOCAL resource.
|
|
168
|
+
# This might be intended or not. If we want remote host stats, we can't easily get them via docker API except via a container.
|
|
169
|
+
# I'll keep it local for now as psutil is local.
|
|
170
|
+
cpu = psutil.cpu_percent(interval=1)
|
|
171
|
+
memory = psutil.virtual_memory()
|
|
172
|
+
disk = psutil.disk_usage('/')
|
|
173
|
+
return f'CPU: {cpu}%, Memory: {memory.percent}%, Disk: {disk.percent}%'
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@tool
|
|
177
|
+
def get_docker_logs(container_name: str, tail: int = 50) -> str:
|
|
178
|
+
"""Gets the last logs of a Docker container"""
|
|
179
|
+
try:
|
|
180
|
+
client = get_docker_client()
|
|
181
|
+
container = client.containers.get(container_name)
|
|
182
|
+
logs = container.logs(tail=tail).decode('utf-8')
|
|
183
|
+
return f'Logs for container {container_name}:\n{logs[-2000:]}'
|
|
184
|
+
except docker.errors.NotFound:
|
|
185
|
+
return f'Error: Container {container_name} not found'
|
|
186
|
+
except Exception as e:
|
|
187
|
+
return f'Error: {str(e)}'
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@tool
|
|
191
|
+
def list_containers() -> str:
|
|
192
|
+
"""Lists active Docker containers with their status"""
|
|
193
|
+
try:
|
|
194
|
+
client = get_docker_client()
|
|
195
|
+
containers = client.containers.list()
|
|
196
|
+
return '\n'.join([f'{c.name} ({c.status})' for c in containers])
|
|
197
|
+
except Exception as e:
|
|
198
|
+
return f'Error listing containers: {e}'
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@tool
|
|
202
|
+
def inspect_container(container_name: str) -> str:
|
|
203
|
+
"""Inspects a Docker container and returns its attributes"""
|
|
204
|
+
try:
|
|
205
|
+
client = get_docker_client()
|
|
206
|
+
container = client.containers.get(container_name)
|
|
207
|
+
return str(container.attrs)
|
|
208
|
+
except docker.errors.NotFound:
|
|
209
|
+
return f'Error: Container {container_name} not found'
|
|
210
|
+
except Exception as e:
|
|
211
|
+
return f'Error: {str(e)}'
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@tool
|
|
215
|
+
def restart_docker_container(container_name: str) -> str:
|
|
216
|
+
"""Restarts a specified Docker container"""
|
|
217
|
+
command_preview = build_command_preview(['docker', 'restart', container_name])
|
|
218
|
+
|
|
219
|
+
def action():
|
|
220
|
+
client = get_docker_client()
|
|
221
|
+
container = client.containers.get(container_name)
|
|
222
|
+
container.restart()
|
|
223
|
+
return f'Container {container_name} restarted'
|
|
224
|
+
|
|
225
|
+
return permission_manager.execute(
|
|
226
|
+
operation='restart_container',
|
|
227
|
+
fn=action,
|
|
228
|
+
fn_kwargs={},
|
|
229
|
+
command_preview=command_preview,
|
|
230
|
+
impact='Restarts the indicated container',
|
|
231
|
+
command_key=f'restart:{container_name}',
|
|
232
|
+
prompt_func=permission_prompt,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@tool
|
|
237
|
+
def create_container(container_image: str, container_name: str) -> str:
|
|
238
|
+
"""Creates and starts a new Docker container with given image and name"""
|
|
239
|
+
command_preview = build_command_preview(['docker', 'run', '-d', '--name', container_name, container_image])
|
|
240
|
+
|
|
241
|
+
def action():
|
|
242
|
+
client = get_docker_client()
|
|
243
|
+
container = client.containers.create(container_image, name=container_name)
|
|
244
|
+
container.start()
|
|
245
|
+
return f'Container {container_name} created and started'
|
|
246
|
+
|
|
247
|
+
return permission_manager.execute(
|
|
248
|
+
operation='create_container',
|
|
249
|
+
fn=action,
|
|
250
|
+
fn_kwargs={},
|
|
251
|
+
command_preview=command_preview,
|
|
252
|
+
impact='Creates and starts a new container',
|
|
253
|
+
command_key=f'create:{container_image}:{container_name}',
|
|
254
|
+
prompt_func=permission_prompt,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@tool
|
|
259
|
+
def delete_container(container_name: str) -> str:
|
|
260
|
+
"""Stops and removes the specified Docker container"""
|
|
261
|
+
command_preview = build_command_preview(['docker', 'rm', '-f', container_name])
|
|
262
|
+
|
|
263
|
+
def action():
|
|
264
|
+
client = get_docker_client()
|
|
265
|
+
container = client.containers.get(container_name)
|
|
266
|
+
container.stop()
|
|
267
|
+
container.remove()
|
|
268
|
+
return f'Container {container_name} deleted'
|
|
269
|
+
|
|
270
|
+
return permission_manager.execute(
|
|
271
|
+
operation='delete_container',
|
|
272
|
+
fn=action,
|
|
273
|
+
fn_kwargs={},
|
|
274
|
+
command_preview=command_preview,
|
|
275
|
+
impact='Stops and removes the indicated container',
|
|
276
|
+
command_key=f'delete:{container_name}',
|
|
277
|
+
prompt_func=permission_prompt,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@tool
|
|
282
|
+
def stop_container(container_name: str) -> str:
|
|
283
|
+
"""Stops the specified Docker container"""
|
|
284
|
+
command_preview = build_command_preview(['docker', 'stop', container_name])
|
|
285
|
+
|
|
286
|
+
def action():
|
|
287
|
+
client = get_docker_client()
|
|
288
|
+
container = client.containers.get(container_name)
|
|
289
|
+
container.stop()
|
|
290
|
+
return f'Container {container_name} stopped'
|
|
291
|
+
|
|
292
|
+
return permission_manager.execute(
|
|
293
|
+
operation='stop_container',
|
|
294
|
+
fn=action,
|
|
295
|
+
fn_kwargs={},
|
|
296
|
+
command_preview=command_preview,
|
|
297
|
+
impact='Stops the indicated container',
|
|
298
|
+
command_key=f'stop:{container_name}',
|
|
299
|
+
prompt_func=permission_prompt,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def background_monitor_task(container_name: str, threshold: float):
|
|
304
|
+
while True:
|
|
305
|
+
try:
|
|
306
|
+
client = get_docker_client()
|
|
307
|
+
container = client.containers.get(container_name)
|
|
308
|
+
stats = container.stats(stream=False)
|
|
309
|
+
mem_usage = stats['memory_stats']['usage']
|
|
310
|
+
mem_limit = stats['memory_stats']['limit']
|
|
311
|
+
mem_percent = (mem_usage / mem_limit) * 100
|
|
312
|
+
if mem_percent > threshold:
|
|
313
|
+
console.print(
|
|
314
|
+
f'[blink bold red]Warning: Memory usage {mem_percent:.2f}% exceeds threshold {threshold}%[/blink bold red]'
|
|
315
|
+
)
|
|
316
|
+
console.print('[yellow]Autodiagnostic[/yellow]')
|
|
317
|
+
alert_msg = f'Warning: Memory usage {mem_percent:.2f}% exceeds threshold {threshold}%'
|
|
318
|
+
run_agent_flow(alert_msg)
|
|
319
|
+
break
|
|
320
|
+
except Exception:
|
|
321
|
+
pass
|
|
322
|
+
time.sleep(10)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@tool
|
|
326
|
+
def start_monitoring(container_name: str, threshold_percent: float) -> str:
|
|
327
|
+
"""Starts memory monitoring for the container and alerts if threshold is exceeded"""
|
|
328
|
+
command_preview = build_command_preview(['monitor', 'memory', container_name, f'threshold={threshold_percent}'])
|
|
329
|
+
|
|
330
|
+
def action():
|
|
331
|
+
t = threading.Thread(target=background_monitor_task, args=(container_name, threshold_percent), daemon=True)
|
|
332
|
+
t.start()
|
|
333
|
+
return f'Monitoring started for container {container_name} with threshold {threshold_percent}%'
|
|
334
|
+
|
|
335
|
+
return permission_manager.execute(
|
|
336
|
+
operation='start_monitoring',
|
|
337
|
+
fn=action,
|
|
338
|
+
fn_kwargs={},
|
|
339
|
+
command_preview=command_preview,
|
|
340
|
+
impact='Starts monitoring that can trigger container restarts',
|
|
341
|
+
command_key=f'monitor:{container_name}:{threshold_percent}',
|
|
342
|
+
prompt_func=permission_prompt,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def sanitize_command(command: str) -> str:
|
|
347
|
+
"""Sanitizes the command to prevent common injection attacks."""
|
|
348
|
+
# Deny chaining characters
|
|
349
|
+
if re.search(r'[;&|]', command):
|
|
350
|
+
raise ValueError('Command chaining characters (;, &, |) are not allowed.')
|
|
351
|
+
|
|
352
|
+
# Deny command substitution
|
|
353
|
+
if re.search(r'\$\(.*\)|`.*`', command):
|
|
354
|
+
raise ValueError('Command substitution is not allowed.')
|
|
355
|
+
|
|
356
|
+
return command
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@tool
|
|
360
|
+
def exec_command(container_name: str, command: str) -> str:
|
|
361
|
+
"""Executes a command in the specified Docker container"""
|
|
362
|
+
try:
|
|
363
|
+
safe_command = sanitize_command(command)
|
|
364
|
+
except ValueError as e:
|
|
365
|
+
return f'Security Error: {e}'
|
|
366
|
+
|
|
367
|
+
command_preview = build_command_preview(['docker', 'exec', container_name, safe_command])
|
|
368
|
+
|
|
369
|
+
def action():
|
|
370
|
+
client = get_docker_client()
|
|
371
|
+
container = client.containers.get(container_name)
|
|
372
|
+
exec_id = container.exec_run(safe_command)
|
|
373
|
+
return exec_id.output.decode('utf-8').strip()
|
|
374
|
+
|
|
375
|
+
return permission_manager.execute(
|
|
376
|
+
operation='exec_command',
|
|
377
|
+
fn=action,
|
|
378
|
+
fn_kwargs={},
|
|
379
|
+
command_preview=command_preview,
|
|
380
|
+
impact='Executes a command in the indicated container',
|
|
381
|
+
command_key=f'exec:{container_name}:{safe_command}',
|
|
382
|
+
prompt_func=permission_prompt,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
tools = [
|
|
387
|
+
check_resource,
|
|
388
|
+
get_docker_logs,
|
|
389
|
+
list_containers,
|
|
390
|
+
inspect_container,
|
|
391
|
+
restart_docker_container,
|
|
392
|
+
create_container,
|
|
393
|
+
delete_container,
|
|
394
|
+
stop_container,
|
|
395
|
+
start_monitoring,
|
|
396
|
+
exec_command,
|
|
397
|
+
]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
if os.getenv('LLM') == 'deepseek':
|
|
401
|
+
from llm.deepseek import llm
|
|
402
|
+
else:
|
|
403
|
+
from llm.chatgpt import llm
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
memory = MemorySaver()
|
|
407
|
+
agent_executor = create_react_agent(llm, tools, checkpointer=memory)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def run_agent_flow(user_input: str):
|
|
411
|
+
initial_state = {'messages': [HumanMessage(content=user_input)]}
|
|
412
|
+
for event in agent_executor.stream(initial_state, global_config):
|
|
413
|
+
if 'agent' in event:
|
|
414
|
+
msg = event['agent']['messages'][0]
|
|
415
|
+
if msg.content:
|
|
416
|
+
console.print('\n[bold magenta]Agent[/bold magenta]')
|
|
417
|
+
console.print(Markdown(msg.content))
|
config_manager.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
class ConfigManager:
|
|
5
|
+
def __init__(self, config_file='config.json'):
|
|
6
|
+
self.config_file = config_file
|
|
7
|
+
self.config = self._load_config()
|
|
8
|
+
|
|
9
|
+
def _load_config(self):
|
|
10
|
+
if not os.path.exists(self.config_file):
|
|
11
|
+
return {
|
|
12
|
+
'mode': 'local',
|
|
13
|
+
'ssh': {
|
|
14
|
+
'host': '',
|
|
15
|
+
'user': '',
|
|
16
|
+
'key_name': ''
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
try:
|
|
20
|
+
with open(self.config_file, 'r', encoding='utf-8') as f:
|
|
21
|
+
return json.load(f)
|
|
22
|
+
except json.JSONDecodeError:
|
|
23
|
+
return {'mode': 'local'}
|
|
24
|
+
|
|
25
|
+
def save_config(self):
|
|
26
|
+
with open(self.config_file, 'w', encoding='utf-8') as f:
|
|
27
|
+
json.dump(self.config, f, indent=2)
|
|
28
|
+
|
|
29
|
+
def set_mode(self, mode):
|
|
30
|
+
if mode not in ['local', 'ssh']:
|
|
31
|
+
raise ValueError("Mode must be 'local' or 'ssh'")
|
|
32
|
+
self.config['mode'] = mode
|
|
33
|
+
self.save_config()
|
|
34
|
+
|
|
35
|
+
def get_mode(self):
|
|
36
|
+
return self.config.get('mode', 'local')
|
|
37
|
+
|
|
38
|
+
def set_ssh_config(self, host, user, key_name):
|
|
39
|
+
self.config['ssh'] = {
|
|
40
|
+
'host': host,
|
|
41
|
+
'user': user,
|
|
42
|
+
'key_name': key_name
|
|
43
|
+
}
|
|
44
|
+
self.save_config()
|
|
45
|
+
|
|
46
|
+
def get_ssh_config(self):
|
|
47
|
+
return self.config.get('ssh', {})
|