2025-11-18 12:11:31 +01:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
2026-03-17 14:45:18 +01:00
|
|
|
from typing import Any
|
|
|
|
|
|
2026-04-19 14:03:31 +02:00
|
|
|
import os, abc, sys, pwd
|
2026-03-21 03:41:10 +01:00
|
|
|
from enum import Flag, auto
|
2025-11-18 12:11:31 +01:00
|
|
|
|
2026-03-20 13:28:33 +01:00
|
|
|
from ..util import pretty_cmd
|
2026-03-20 12:53:07 +01:00
|
|
|
from ..log import *
|
2026-04-16 11:23:05 +02:00
|
|
|
from ..base import Result
|
|
|
|
|
from ..ExecContext import ExecContext
|
2026-03-18 07:09:01 +01:00
|
|
|
from urllib.parse import urlparse
|
2025-11-18 12:11:31 +01:00
|
|
|
|
2026-03-18 05:52:42 +01:00
|
|
|
class SSHClient(ExecContext):
|
2025-11-18 12:11:31 +01:00
|
|
|
|
2026-03-21 03:41:10 +01:00
|
|
|
class Caps(Flag):
|
|
|
|
|
LogOutput = auto()
|
|
|
|
|
Interactive = auto()
|
2026-04-19 14:04:35 +02:00
|
|
|
ModEnv = auto()
|
2026-03-21 03:41:10 +01:00
|
|
|
Wd = auto()
|
|
|
|
|
|
|
|
|
|
def __init__(self, uri: str, caps: Caps=Caps(0), *args, **kwargs) -> None:
|
2026-03-18 07:09:01 +01:00
|
|
|
super().__init__(uri=uri, *args, **kwargs)
|
2026-03-21 03:41:10 +01:00
|
|
|
self.__caps = caps
|
2026-03-18 07:27:59 +01:00
|
|
|
try:
|
|
|
|
|
parsed = urlparse(uri)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
log(ERR, f'Failed to parse SSH URI "{uri}"')
|
|
|
|
|
raise
|
2026-04-10 14:04:19 +02:00
|
|
|
|
2026-03-18 07:09:01 +01:00
|
|
|
self.__hostname = parsed.hostname
|
2026-04-10 14:04:19 +02:00
|
|
|
if self.__hostname is None:
|
|
|
|
|
raise Exception(f'Can\'t parse host name from SSH URI "{uri}"')
|
2026-03-21 04:31:00 +01:00
|
|
|
self.__port = parsed.port
|
2026-03-20 13:28:33 +01:00
|
|
|
self.__password = parsed.password
|
2026-03-18 07:09:01 +01:00
|
|
|
self.__username = parsed.username
|
2025-11-18 12:11:31 +01:00
|
|
|
|
|
|
|
|
@abc.abstractmethod
|
2026-03-21 03:41:10 +01:00
|
|
|
async def _run_ssh(
|
|
|
|
|
cmd: list[str],
|
|
|
|
|
wd: str|None,
|
|
|
|
|
verbose: bool,
|
2026-04-15 14:02:44 +02:00
|
|
|
cmd_input: bytes|None,
|
2026-04-19 14:04:35 +02:00
|
|
|
mod_env: dict[str, str]|None,
|
2026-03-21 03:41:10 +01:00
|
|
|
interactive: bool,
|
|
|
|
|
log_prefix: str
|
|
|
|
|
) -> Result:
|
2025-11-18 12:11:31 +01:00
|
|
|
pass
|
|
|
|
|
|
2026-03-18 05:52:42 +01:00
|
|
|
async def _run(
|
2026-03-18 10:22:21 +01:00
|
|
|
self,
|
lib.ExecContext: Align .sudo() prototype to .run()
ExecContext's .sudo() omits many of run()'s parameters, and this
commit adds them. To avoid redundancy around repeating and massaging
the long parameter list of both functions and their return values, it
also adds some deeper changes:
- Make run(), _run(), sudo() and _sudo() always return instances of
Result. Before it was allowed to return a triplet of stdout,
stderr, and exit status.
- Have ExecContext stay out of the business of decoding the result
entirely. Result provides a convenience method .decode()
operating on stdout and stderr and leaves the decision to the
caller.
This entails miniscule adaptations in calling code, namely in
App.os_release, util.get_profile_env() and CmdListRepos._run().
- Wrap the _run() and _sudo() callbacks in a context manager object
of type CallContext to avoid code duplication.
- Consistently name the first argument to run(), _run(), sudo() and
_sudo() "cmd", not "args". The latter suggests that the caller is
omitting the executable, which is not the case.
Signed-off-by: Jan Lindemann <jan@janware.com>
2026-03-19 11:38:16 +01:00
|
|
|
cmd: list[str],
|
2026-03-18 10:22:21 +01:00
|
|
|
wd: str|None,
|
|
|
|
|
verbose: bool,
|
2026-04-15 14:02:44 +02:00
|
|
|
cmd_input: bytes|None,
|
2026-04-19 14:04:35 +02:00
|
|
|
mod_env: dict[str, str]|None,
|
2026-03-18 10:22:21 +01:00
|
|
|
interactive: bool,
|
|
|
|
|
log_prefix: str
|
|
|
|
|
) -> Result:
|
|
|
|
|
|
2026-03-19 07:13:12 +01:00
|
|
|
def __log(prio: int, *args):
|
2026-03-18 10:22:21 +01:00
|
|
|
log(prio, log_prefix, *args)
|
2026-03-18 05:14:07 +01:00
|
|
|
|
2026-03-19 07:13:12 +01:00
|
|
|
def __log_block(prio: int, title: str, block: str):
|
2026-03-21 03:41:10 +01:00
|
|
|
if self.__caps & self.Caps.LogOutput:
|
|
|
|
|
return
|
2026-04-19 14:34:21 +02:00
|
|
|
if not block:
|
|
|
|
|
return
|
2026-03-19 07:13:12 +01:00
|
|
|
encoding = sys.stdout.encoding or 'utf-8'
|
|
|
|
|
block = block.decode(encoding).strip()
|
|
|
|
|
if not block:
|
|
|
|
|
return
|
|
|
|
|
delim = f'---- {title} ----'
|
|
|
|
|
__log(prio, f',{delim}')
|
|
|
|
|
for line in block.splitlines():
|
|
|
|
|
__log(prio, '|', line)
|
|
|
|
|
__log(prio, f'`{delim}')
|
|
|
|
|
|
2026-03-21 03:41:10 +01:00
|
|
|
if wd is not None and not self.__caps & self.Caps.Wd:
|
lib.ExecContext: Align .sudo() prototype to .run()
ExecContext's .sudo() omits many of run()'s parameters, and this
commit adds them. To avoid redundancy around repeating and massaging
the long parameter list of both functions and their return values, it
also adds some deeper changes:
- Make run(), _run(), sudo() and _sudo() always return instances of
Result. Before it was allowed to return a triplet of stdout,
stderr, and exit status.
- Have ExecContext stay out of the business of decoding the result
entirely. Result provides a convenience method .decode()
operating on stdout and stderr and leaves the decision to the
caller.
This entails miniscule adaptations in calling code, namely in
App.os_release, util.get_profile_env() and CmdListRepos._run().
- Wrap the _run() and _sudo() callbacks in a context manager object
of type CallContext to avoid code duplication.
- Consistently name the first argument to run(), _run(), sudo() and
_sudo() "cmd", not "args". The latter suggests that the caller is
omitting the executable, which is not the case.
Signed-off-by: Jan Lindemann <jan@janware.com>
2026-03-19 11:38:16 +01:00
|
|
|
cmd = ['cd', wd, '&&', *cmd]
|
2026-03-18 05:14:07 +01:00
|
|
|
|
2026-03-21 03:41:10 +01:00
|
|
|
if interactive and not self.__caps & self.Caps.Interactive:
|
2026-03-18 05:14:07 +01:00
|
|
|
raise NotImplementedError('Interactive SSH is not yet implemented')
|
|
|
|
|
|
2026-04-19 14:04:35 +02:00
|
|
|
if mod_env is not None and not self.__caps & self.Caps.ModEnv:
|
2026-03-18 05:14:07 +01:00
|
|
|
raise NotImplementedError('Passing an environment to SSH commands is not yet implemented')
|
|
|
|
|
|
2026-03-21 03:41:10 +01:00
|
|
|
ret = await self._run_ssh(
|
|
|
|
|
cmd=cmd,
|
|
|
|
|
wd=wd,
|
|
|
|
|
verbose=verbose,
|
|
|
|
|
cmd_input=cmd_input,
|
2026-04-19 14:04:35 +02:00
|
|
|
mod_env=mod_env,
|
2026-03-21 03:41:10 +01:00
|
|
|
interactive=interactive,
|
|
|
|
|
log_prefix=log_prefix
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-19 07:13:12 +01:00
|
|
|
if verbose:
|
|
|
|
|
__log_block(NOTICE, 'stdout', ret.stdout)
|
|
|
|
|
__log_block(NOTICE, 'stderr', ret.stderr)
|
|
|
|
|
if ret.status != 0:
|
|
|
|
|
__log(WARNING, f'Exit code {ret.status}')
|
2026-03-21 03:41:10 +01:00
|
|
|
|
2026-03-19 07:13:12 +01:00
|
|
|
return ret
|
2026-03-17 14:45:18 +01:00
|
|
|
|
2026-03-18 05:58:12 +01:00
|
|
|
@property
|
2026-04-17 18:04:52 +02:00
|
|
|
def hostname(self) -> str|None:
|
2026-03-18 05:58:12 +01:00
|
|
|
return self.__hostname
|
|
|
|
|
|
2026-03-21 04:31:00 +01:00
|
|
|
@property
|
2026-04-17 18:04:52 +02:00
|
|
|
def port(self) -> int|None:
|
2026-03-21 04:31:00 +01:00
|
|
|
return self.__port
|
|
|
|
|
|
2026-03-18 05:58:12 +01:00
|
|
|
def set_password(self, password: str) -> None:
|
|
|
|
|
self.__password = password
|
|
|
|
|
|
|
|
|
|
@property
|
2026-04-17 18:04:52 +02:00
|
|
|
def password(self) -> str|None:
|
2026-03-18 05:58:12 +01:00
|
|
|
return self.__password
|
|
|
|
|
|
|
|
|
|
def set_username(self, username: str) -> None:
|
|
|
|
|
self.__username = username
|
|
|
|
|
|
2026-04-19 14:03:31 +02:00
|
|
|
def _username(self) -> str:
|
|
|
|
|
if self.__username is None:
|
|
|
|
|
return pwd.getpwuid(os.getuid()).pw_name
|
2026-03-18 05:58:12 +01:00
|
|
|
return self.__username
|
|
|
|
|
|
2026-04-17 17:37:11 +02:00
|
|
|
def ssh_client(*args, type: str|list[str]|None=None, **kwargs) -> SSHClient: # export
|
2026-03-20 13:28:33 +01:00
|
|
|
from importlib import import_module
|
|
|
|
|
errors: list[str] = []
|
2026-04-17 17:37:11 +02:00
|
|
|
if type is None:
|
|
|
|
|
val = os.getenv('JW_DEFAULT_SSH_CLIENT')
|
|
|
|
|
if val is not None:
|
|
|
|
|
type = val.split(',')
|
|
|
|
|
else:
|
|
|
|
|
type = ['AsyncSSH', 'Paramiko', 'Exec']
|
2026-04-11 10:20:28 +02:00
|
|
|
if isinstance(type, str):
|
|
|
|
|
type = [type]
|
|
|
|
|
for name in type:
|
2026-03-20 13:28:33 +01:00
|
|
|
try:
|
2026-03-21 04:29:58 +01:00
|
|
|
ret = getattr(import_module(f'jw.pkg.lib.ec.ssh.{name}'), name)(*args, **kwargs)
|
|
|
|
|
log(INFO, f'Using SSH-client "{name}"')
|
|
|
|
|
return ret
|
2026-03-20 13:28:33 +01:00
|
|
|
except Exception as e:
|
|
|
|
|
msg = f'Can\'t instantiate SSH client class {name} ({str(e)})'
|
|
|
|
|
errors.append(msg)
|
|
|
|
|
log(DEBUG, f'{msg}, trying next')
|
|
|
|
|
msg = f'No working SSH clients for {" ".join(args)}'
|
|
|
|
|
log(ERR, f'----- {msg}')
|
|
|
|
|
for error in errors:
|
|
|
|
|
log(ERR, error)
|
|
|
|
|
raise Exception(msg)
|