fujin-cli 0.1.0__py3-none-any.whl → 0.3.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.

Potentially problematic release.


This version of fujin-cli might be problematic. Click here for more details.

@@ -0,0 +1,71 @@
1
+ from dataclasses import dataclass
2
+ from typing import Annotated
3
+
4
+ import cappa
5
+
6
+ from fujin.commands import BaseCommand
7
+
8
+
9
+ @cappa.command(help="Manage web proxy.")
10
+ @dataclass
11
+ class Proxy(BaseCommand):
12
+ @cappa.command(help="Install the proxy on the remote host")
13
+ def install(self):
14
+ with self.connection() as conn:
15
+ self.create_web_proxy(conn).install()
16
+
17
+ @cappa.command(help="Uninstall the proxy from the remote host")
18
+ def uninstall(self):
19
+ with self.connection() as conn:
20
+ self.create_web_proxy(conn).uninstall()
21
+
22
+ @cappa.command(help="Start the proxy on the remote host")
23
+ def start(self):
24
+ with self.connection() as conn:
25
+ self.create_web_proxy(conn).start()
26
+ self.stdout.output("[green]Proxy started successfully![/green]")
27
+
28
+ @cappa.command(help="Stop the proxy on the remote host")
29
+ def stop(self):
30
+ with self.connection() as conn:
31
+ self.create_web_proxy(conn).stop()
32
+ self.stdout.output("[green]Proxy stopped successfully![/green]")
33
+
34
+ @cappa.command(help="Restart the proxy on the remote host")
35
+ def restart(self):
36
+ with self.connection() as conn:
37
+ self.create_web_proxy(conn).restart()
38
+ self.stdout.output("[green]Proxy restarted successfully![/green]")
39
+
40
+ @cappa.command(help="Check the status of the proxy on the remote host")
41
+ def status(self):
42
+ with self.connection() as conn:
43
+ self.create_web_proxy(conn).status()
44
+
45
+ @cappa.command(help="View the logs of the proxy on the remote host")
46
+ def logs(self):
47
+ with self.connection() as conn:
48
+ self.create_web_proxy(conn).logs()
49
+
50
+ @cappa.command(
51
+ name="export-config",
52
+ help="Export the proxy configuration file locally to the .fujin directory",
53
+ )
54
+ def export_config(
55
+ self,
56
+ overwrite: Annotated[
57
+ bool, cappa.Arg(help="overwrite any existing config file")
58
+ ] = False,
59
+ ):
60
+ with self.connection() as conn:
61
+ proxy = self.create_web_proxy(conn)
62
+ if proxy.config_file.exists() and not overwrite:
63
+ self.stdout.output(
64
+ f"[blue]{proxy.config_file} already exists, use --overwrite to overwrite it content.[/blue]"
65
+ )
66
+ else:
67
+ self.config.local_config_dir.mkdir(exist_ok=True)
68
+ proxy.export_config()
69
+ self.stdout.output(
70
+ f"[green]Config file successfully to {proxy.config_file}[/green]"
71
+ )
@@ -0,0 +1,42 @@
1
+ from dataclasses import dataclass
2
+ from typing import Annotated
3
+
4
+ import cappa
5
+ from rich.prompt import Confirm
6
+
7
+ from fujin.commands import BaseCommand
8
+
9
+
10
+ @cappa.command(
11
+ help="Prune old version artifacts, keeping only the specified number of recent versions"
12
+ )
13
+ @dataclass
14
+ class Prune(BaseCommand):
15
+ keep: Annotated[
16
+ int,
17
+ cappa.Arg(
18
+ short="-k",
19
+ long="--keep",
20
+ help="Number of version artifacts to retain (minimum 1)",
21
+ ),
22
+ ] = 2
23
+
24
+ def __call__(self):
25
+ if self.keep < 1:
26
+ raise cappa.Exit("The minimum value for the --keep option is 1", code=1)
27
+ with self.connection() as conn, conn.cd(self.app_dir):
28
+ result = conn.run(
29
+ f"sed -n '{self.keep + 1},$p' .versions", hide=True
30
+ ).stdout.strip()
31
+ result_list = result.split("\n")
32
+ if result == "":
33
+ self.stdout.output("[blue]No versions to prune[/blue]")
34
+ return
35
+ if not Confirm.ask(
36
+ f"[red]The following versions will be permanently deleted: {', '.join(result_list)}. This action is irreversible. Are you sure you want to proceed?[/red]"
37
+ ):
38
+ return
39
+ to_prune = [f"{self.app_dir}/v{v}" for v in result_list]
40
+ conn.run(f"rm -r {' '.join(to_prune)}", warn=True)
41
+ conn.run(f"sed -i '{self.keep + 1},$d' .versions", warn=True)
42
+ self.stdout.output("[green]Pruning completed successfully[/green]")
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+
5
+ import cappa
6
+
7
+ from fujin.commands import BaseCommand
8
+ from .deploy import Deploy
9
+
10
+
11
+ @cappa.command(help="Redeploy the application to apply code and environment changes")
12
+ class Redeploy(BaseCommand):
13
+ def __call__(self):
14
+ deploy = Deploy()
15
+ deploy.build_app()
16
+ local_requirements = hashlib.md5(
17
+ self.config.requirements.read_bytes()
18
+ ).hexdigest()
19
+ with self.app_environment() as conn:
20
+ hook_manager = self.create_hook_manager(conn)
21
+ hook_manager.pre_deploy()
22
+ current_host_version = conn.run(
23
+ "head -n 1 .versions", warn=True, hide=True
24
+ ).stdout.strip()
25
+ try:
26
+ host_requirements = (
27
+ conn.run(
28
+ f"md5sum v{current_host_version}/requirements.txt",
29
+ warn=True,
30
+ hide=True,
31
+ )
32
+ .stdout.strip()
33
+ .split()[0]
34
+ )
35
+ skip_requirements = host_requirements == local_requirements
36
+ except IndexError:
37
+ skip_requirements = False
38
+ deploy.transfer_files(conn, skip_requirements=skip_requirements)
39
+ if skip_requirements and current_host_version != self.config.version:
40
+ conn.run(
41
+ f"cp v{current_host_version}/requirements.txt {deploy.versioned_assets_dir}/requirements.txt "
42
+ )
43
+ deploy.install_project(conn, skip_setup=skip_requirements)
44
+ deploy.release(conn)
45
+ self.create_process_manager(conn).restart_services()
46
+ deploy.update_version_history(conn)
47
+ hook_manager.post_deploy()
48
+ self.stdout.output("[green]Redeployment completed successfully![/green]")
@@ -0,0 +1,49 @@
1
+ from dataclasses import dataclass
2
+
3
+ import cappa
4
+ from rich.prompt import Prompt, Confirm
5
+
6
+ from fujin.commands import BaseCommand
7
+ from fujin.commands.deploy import Deploy
8
+
9
+
10
+ @cappa.command(help="Rollback application to a previous version")
11
+ @dataclass
12
+ class Rollback(BaseCommand):
13
+ def __call__(self):
14
+ with self.app_environment() as conn:
15
+ result = conn.run(
16
+ "sed -n '2,$p' .versions", warn=True, hide=True
17
+ ).stdout.strip()
18
+ if not result:
19
+ self.stdout.output("[blue]No rollback targets available")
20
+ return
21
+ versions: list[str] = result.split("\n")
22
+ try:
23
+ version = Prompt.ask(
24
+ "Enter the version you want to rollback to:",
25
+ choices=versions,
26
+ default=versions[0],
27
+ )
28
+ except KeyboardInterrupt as e:
29
+ raise cappa.Exit("Rollback aborted by user.", code=0) from e
30
+
31
+ current_app_version = conn.run(
32
+ "head -n 1 .versions", warn=True, hide=True
33
+ ).stdout.strip()
34
+ versions_to_clean = [current_app_version] + versions[
35
+ : versions.index(version)
36
+ ]
37
+ confirm = Confirm.ask(
38
+ f"[blue]Rolling back to v{version} will permanently delete versions {', '.join(versions_to_clean)}. This action is irreversible. Are you sure you want to proceed?[/blue]"
39
+ )
40
+ if not confirm:
41
+ return
42
+ deploy = Deploy()
43
+ deploy.install_project(conn, version)
44
+ self.create_process_manager(conn).restart_services()
45
+ conn.run(f"rm -r {' '.join(f'v{v}' for v in versions_to_clean)}", warn=True)
46
+ conn.run(f"sed -i '1,/{version}/{{/{version}/!d}}' .versions", warn=True)
47
+ self.stdout.output(
48
+ f"[green]Rollback to version {version} from {current_app_version} completed successfully![/green]"
49
+ )
@@ -0,0 +1,11 @@
1
+ from dataclasses import dataclass
2
+
3
+ import cappa
4
+
5
+ from fujin.commands import BaseCommand
6
+
7
+
8
+ @cappa.command(help="")
9
+ @dataclass
10
+ class Secrets(BaseCommand):
11
+ pass
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from functools import partial
5
+ from typing import Annotated
6
+
7
+ import cappa
8
+
9
+ from fujin.commands import BaseCommand
10
+
11
+
12
+ @cappa.command(help="Manage server operations")
13
+ class Server(BaseCommand):
14
+ @cappa.command(help="Display information about the host system")
15
+ def info(self):
16
+ with self.connection() as conn:
17
+ result = conn.run(f"command -v fastfetch", warn=True, hide=True)
18
+ if result.ok:
19
+ conn.run("fastfetch", pty=True)
20
+ else:
21
+ self.stdout.output(conn.run("cat /etc/os-release", hide=True).stdout)
22
+
23
+ @cappa.command(help="Setup uv, web proxy, and install necessary dependencies")
24
+ def bootstrap(self):
25
+ with self.connection() as conn:
26
+ hook_manager = self.create_hook_manager(conn)
27
+ hook_manager.pre_bootstrap()
28
+ conn.run("sudo apt update && sudo apt upgrade -y", pty=True)
29
+ conn.run("sudo apt install -y sqlite3 curl rsync", pty=True)
30
+ result = conn.run("command -v uv", warn=True)
31
+ if not result.ok:
32
+ conn.run("curl -LsSf https://astral.sh/uv/install.sh | sh")
33
+ conn.run("uv tool update-shell")
34
+ conn.run("uv tool install fastfetch-bin-edge")
35
+ self.create_web_proxy(conn).install()
36
+ hook_manager.post_bootstrap()
37
+ self.stdout.output(
38
+ "[green]Server bootstrap completed successfully![/green]"
39
+ )
40
+
41
+ @cappa.command(help="Stop and uninstall the web proxy")
42
+ def uninstall_proxy(self):
43
+ with self.connection() as conn:
44
+ self.create_web_proxy(conn).uninstall()
45
+
46
+ @cappa.command(
47
+ help="Execute an arbitrary command on the server, optionally in interactive mode"
48
+ )
49
+ def exec(
50
+ self,
51
+ command: str,
52
+ interactive: Annotated[bool, cappa.Arg(default=False, short="-i")],
53
+ appenv: Annotated[
54
+ bool,
55
+ cappa.Arg(
56
+ default=False,
57
+ long="--appenv",
58
+ help="Change to app directory and enable app environment",
59
+ ),
60
+ ],
61
+ ):
62
+ context = self.app_environment() if appenv else self.connection()
63
+ with context as conn:
64
+ if interactive:
65
+ conn.run(command, pty=interactive, warn=True)
66
+ else:
67
+ self.stdout.output(conn.run(command, hide=True).stdout)
68
+
69
+ @cappa.command(
70
+ name="create-user", help="Create a new user with sudo and ssh access"
71
+ )
72
+ def create_user(
73
+ self,
74
+ name: str,
75
+ with_password: Annotated[bool, cappa.Arg(long="--with-password")] = False,
76
+ ):
77
+ with self.connection() as conn:
78
+ run_pty = partial(conn.run, pty=True)
79
+ run_pty(
80
+ f"sudo adduser --disabled-password --gecos '' {name}",
81
+ )
82
+ run_pty(f"sudo mkdir -p /home/{name}/.ssh")
83
+ run_pty(f"sudo cp ~/.ssh/authorized_keys /home/{name}/.ssh/")
84
+ run_pty(f"sudo chown -R {name}:{name} /home/{name}/.ssh")
85
+ if with_password:
86
+ password = secrets.token_hex(8)
87
+ run_pty(f"echo '{name}:{password}' | sudo chpasswd")
88
+ self.stdout.output(f"[green]Generated password: [/green]{password}")
89
+ run_pty(f"sudo chmod 700 /home/{name}/.ssh")
90
+ run_pty(f"sudo chmod 600 /home/{name}/.ssh/authorized_keys")
91
+ run_pty(f"echo '{name} ALL=(ALL) NOPASSWD:ALL' | sudo tee -a /etc/sudoers")
92
+ self.stdout.output(f"[green]New user {name} created successfully![/green]")
fujin/commands/up.py ADDED
@@ -0,0 +1,15 @@
1
+ import cappa
2
+ from fujin.commands import BaseCommand
3
+
4
+ from .deploy import Deploy
5
+ from .server import Server
6
+
7
+
8
+ @cappa.command(help="Run everything required to deploy an application to a fresh host.")
9
+ class Up(BaseCommand):
10
+ def __call__(self):
11
+ Server().bootstrap()
12
+ Deploy()()
13
+ self.stdout.output(
14
+ "[green]Server bootstrapped and application deployed successfully![/green]"
15
+ )
fujin/config.py ADDED
@@ -0,0 +1,290 @@
1
+ """
2
+ Fujin uses a ``fujin.toml`` file at the root of your project for configuration. Below are all available configuration options.
3
+
4
+ app
5
+ ---
6
+ The name of your project or application. Must be a valid Python package name.
7
+
8
+ app_bin
9
+ -------
10
+ Path to your application's executable. Used by the **app** subcommand for remote execution.
11
+ Default: ``.venv/bin/{app}``
12
+
13
+ version
14
+ --------
15
+ The version of your project to build and deploy. If not specified, automatically parsed from ``pyproject.toml`` under ``project.version``.
16
+
17
+ python_version
18
+ --------------
19
+ The Python version for your virtualenv. If not specified, automatically parsed from ``.python-version`` file.
20
+
21
+ versions_to_keep
22
+ ----------------
23
+ The number of versions to keep on the host. After each deploy, older versions are pruned based on this setting. By default, it keeps the latest 5 versions,
24
+ set this to `None` to never automatically prune.
25
+
26
+ build_command
27
+ -------------
28
+ The command to use to build your project's distribution file.
29
+
30
+ distfile
31
+ --------
32
+ Path to your project's distribution file. This should be the main artifact containing everything needed to run your project on the server.
33
+ Supports version placeholder, e.g., ``dist/app_name-{version}-py3-none-any.whl``
34
+
35
+ release_command
36
+ ---------------
37
+ Optional command to run at the end of deployment (e.g., database migrations).
38
+
39
+ requirements
40
+ ------------
41
+ Path to your requirements file.
42
+ Default: ``requirements.txt``
43
+
44
+ Webserver
45
+ ---------
46
+
47
+ type
48
+ ~~~~
49
+ The reverse proxy implementation to use. Available options:
50
+
51
+ - ``fujin.proxies.caddy`` (default)
52
+ - ``fujin.proxies.nginx``
53
+ - ``fujin.proxies.dummy`` (disables proxy functionality)
54
+
55
+ upstream
56
+ ~~~~~~~~
57
+ The address where your web application listens for requests. Supports any value compatible with your chosen web proxy:
58
+
59
+ - HTTP address (e.g., ``localhost:8000``)
60
+ - Unix socket (e.g., ``unix//run/project.sock``)
61
+
62
+ statics
63
+ ~~~~~~~
64
+
65
+ Defines the mapping of URL paths to local directories for serving static files. The syntax and support for static
66
+ file serving depend on the selected reverse proxy.
67
+
68
+ Example:
69
+
70
+ .. code-block:: toml
71
+
72
+ [webserver]
73
+ upstream = "unix//run/project.sock"
74
+ type = "fujin.proxies.caddy"
75
+ statics = { "/static/*" = "/var/www/example.com/static/" }
76
+
77
+ processes
78
+ ---------
79
+
80
+ A mapping of process names to commands that will be managed by the process manager. Define as many processes as needed, but
81
+ when using any proxy other than ``fujin.proxies.dummy``, a ``web`` process must be declared. Refer to the ``apps_dir``
82
+ setting on the host to understand how ``app_dir`` is determined.
83
+
84
+ Example:
85
+
86
+ .. code-block:: toml
87
+
88
+ [processes]
89
+ web = ".venv/bin/gunicorn myproject.wsgi:application"
90
+
91
+
92
+ .. note::
93
+
94
+ Commands are relative to your ``app_dir``. When generating systemd service files, the full path is automatically constructed.
95
+
96
+ Host Configuration
97
+ -------------------
98
+
99
+ ip
100
+ ~~
101
+ The IP address or hostname of the remote host.
102
+
103
+ domain_name
104
+ ~~~~~~~~~~~
105
+ The domain name pointing to this host. Used for web proxy configuration.
106
+
107
+ user
108
+ ~~~~
109
+ The login user for running remote tasks. Should have passwordless sudo access for optimal operation.
110
+
111
+ .. note::
112
+
113
+ You can create a user with these requirements using the ``fujin server create-user`` command.
114
+
115
+ envfile
116
+ ~~~~~~~
117
+ Path to the production environment file that will be copied to the host.
118
+
119
+ apps_dir
120
+ ~~~~~~~~
121
+
122
+ Base directory for project storage on the host. Path is relative to user's home directory.
123
+ Default: ``.local/share/fujin``. This value determines your project's ``app_dir``, which is ``{apps_dir}/{app}``.
124
+
125
+ password_env
126
+ ~~~~~~~~~~~~
127
+
128
+ Environment variable containing the user's password. Only needed if the user cannot run sudo without a password.
129
+
130
+ ssh_port
131
+ ~~~~~~~~
132
+
133
+ SSH port for connecting to the host.
134
+ Default: ``22``
135
+
136
+ key_filename
137
+ ~~~~~~~~~~~~
138
+
139
+ Path to the SSH private key file for authentication. Optional if using your system's default key location.
140
+
141
+ aliases
142
+ -------
143
+
144
+ A mapping of shortcut names to Fujin commands. Allows you to create convenient shortcuts for commonly used commands.
145
+
146
+ Example:
147
+
148
+ .. code-block:: toml
149
+
150
+ [aliases]
151
+ console = "app exec -i shell_plus" # open an interactive django shell
152
+ dbconsole = "app exec -i dbshell" # open an interactive django database shell
153
+ shell = "server exec --appenv -i bash" # SSH into the project directory with environment variables loaded
154
+
155
+
156
+ """
157
+
158
+ from __future__ import annotations
159
+
160
+ import os
161
+ import sys
162
+ from pathlib import Path
163
+
164
+ import msgspec
165
+
166
+ from .errors import ImproperlyConfiguredError
167
+
168
+ if sys.version_info >= (3, 11):
169
+ import tomllib
170
+ else:
171
+ import tomli as tomllib
172
+
173
+ from .hooks import HooksDict
174
+
175
+
176
+ class Config(msgspec.Struct, kw_only=True):
177
+ app_name: str = msgspec.field(name="app")
178
+ app_bin: str = ".venv/bin/{app}"
179
+ version: str = msgspec.field(default_factory=lambda: read_version_from_pyproject())
180
+ versions_to_keep: int | None = 5
181
+ python_version: str = msgspec.field(default_factory=lambda: find_python_version())
182
+ build_command: str
183
+ release_command: str | None = None
184
+ skip_project_install: bool = False
185
+ distfile: str
186
+ aliases: dict[str, str] = msgspec.field(default_factory=dict)
187
+ host: HostConfig
188
+ processes: dict[str, str] = msgspec.field(default_factory=dict)
189
+ process_manager: str = "fujin.process_managers.systemd"
190
+ webserver: Webserver
191
+ _requirements: str = msgspec.field(name="requirements", default="requirements.txt")
192
+ hooks: HooksDict = msgspec.field(default_factory=dict)
193
+ local_config_dir: Path = Path(".fujin")
194
+
195
+ def __post_init__(self):
196
+ self.app_bin = self.app_bin.format(app=self.app_name)
197
+ # self._distfile = self._distfile.format(version=self.version)
198
+
199
+ if "web" not in self.processes and self.webserver.type != "fujin.proxies.dummy":
200
+ raise ValueError(
201
+ "Missing web process or set the proxy to 'fujin.proxies.dummy' to disable the use of a proxy"
202
+ )
203
+
204
+ @property
205
+ def requirements(self) -> Path:
206
+ return Path(self._requirements)
207
+
208
+ def get_distfile_path(self, version: str | None = None) -> Path:
209
+ version = version or self.version
210
+ return Path(self.distfile.format(version=version))
211
+
212
+ @classmethod
213
+ def read(cls) -> Config:
214
+ fujin_toml = Path("fujin.toml")
215
+ if not fujin_toml.exists():
216
+ raise ImproperlyConfiguredError(
217
+ "No fujin.toml file found in the current directory"
218
+ )
219
+ try:
220
+ return msgspec.toml.decode(fujin_toml.read_text(), type=cls)
221
+ except msgspec.ValidationError as e:
222
+ raise ImproperlyConfiguredError(f"Improperly configured, {e}") from e
223
+
224
+
225
+ class HostConfig(msgspec.Struct, kw_only=True):
226
+ ip: str
227
+ domain_name: str
228
+ user: str
229
+ _envfile: str = msgspec.field(name="envfile")
230
+ apps_dir: str = ".local/share/fujin"
231
+ password_env: str | None = None
232
+ ssh_port: int = 22
233
+ _key_filename: str | None = msgspec.field(name="key_filename", default=None)
234
+
235
+ def __post_init__(self):
236
+ self.apps_dir = f"/home/{self.user}/{self.apps_dir}"
237
+
238
+ def to_dict(self):
239
+ d = {f: getattr(self, f) for f in self.__struct_fields__}
240
+ d.pop("_key_filename")
241
+ d.pop("_envfile")
242
+ d["key_filename"] = self.key_filename
243
+ d["envfile"] = self.envfile
244
+ return d
245
+
246
+ @property
247
+ def envfile(self) -> Path:
248
+ return Path(self._envfile)
249
+
250
+ @property
251
+ def key_filename(self) -> Path | None:
252
+ if self._key_filename:
253
+ return Path(self._key_filename)
254
+
255
+ @property
256
+ def password(self) -> str | None:
257
+ if not self.password_env:
258
+ return
259
+ password = os.getenv(self.password_env)
260
+ if not password:
261
+ msg = f"Env {self.password_env} can not be found"
262
+ raise ImproperlyConfiguredError(msg)
263
+ return password
264
+
265
+ def get_app_dir(self, app_name: str) -> str:
266
+ return f"{self.apps_dir}/{app_name}"
267
+
268
+
269
+ class Webserver(msgspec.Struct):
270
+ upstream: str
271
+ type: str = "fujin.proxies.caddy"
272
+ statics: dict[str, str] = msgspec.field(default_factory=dict)
273
+
274
+
275
+ def read_version_from_pyproject():
276
+ try:
277
+ return tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]
278
+ except (FileNotFoundError, KeyError) as e:
279
+ raise msgspec.ValidationError(
280
+ "Project version was not found in the pyproject.toml file, define it manually"
281
+ ) from e
282
+
283
+
284
+ def find_python_version():
285
+ py_version_file = Path(".python-version")
286
+ if not py_version_file.exists():
287
+ raise msgspec.ValidationError(
288
+ f"Add a python_version key or a .python-version file"
289
+ )
290
+ return py_version_file.read_text().strip()
fujin/connection.py ADDED
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from functools import partial
5
+ from typing import TYPE_CHECKING
6
+
7
+ import cappa
8
+ from fabric import Connection
9
+ from invoke import Responder
10
+ from invoke.exceptions import UnexpectedExit
11
+ from paramiko.ssh_exception import (
12
+ AuthenticationException,
13
+ NoValidConnectionsError,
14
+ SSHException,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from fujin.config import HostConfig
19
+
20
+
21
+ def _get_watchers(host: HostConfig) -> list[Responder]:
22
+ if not host.password:
23
+ return []
24
+ return [
25
+ Responder(
26
+ pattern=r"\[sudo\] password:",
27
+ response=f"{host.password}\n",
28
+ ),
29
+ Responder(
30
+ pattern=rf"\[sudo\] password for {host.user}:",
31
+ response=f"{host.password}\n",
32
+ ),
33
+ ]
34
+
35
+
36
+ @contextmanager
37
+ def host_connection(host: HostConfig) -> Connection:
38
+ connect_kwargs = None
39
+ if host.key_filename:
40
+ connect_kwargs = {"key_filename": str(host.key_filename)}
41
+ elif host.password:
42
+ connect_kwargs = {"password": host.password}
43
+ conn = Connection(
44
+ host.ip,
45
+ user=host.user,
46
+ port=host.ssh_port,
47
+ connect_kwargs=connect_kwargs,
48
+ )
49
+ try:
50
+ conn.run = partial(
51
+ conn.run,
52
+ env={
53
+ "PATH": f"/home/{host.user}/.cargo/bin:/home/{host.user}/.local/bin:$PATH"
54
+ },
55
+ watchers=_get_watchers(host),
56
+ )
57
+ yield conn
58
+ except AuthenticationException as e:
59
+ msg = f"Authentication failed for {host.user}@{host.ip} -p {host.ssh_port}.\n"
60
+ if host.key_filename:
61
+ msg += f"An SSH key was provided at {host.key_filename.resolve()}. Please verify its validity and correctness."
62
+ elif host.password:
63
+ msg += f"A password was provided through the environment variable {host.password_env}. Please ensure it is correct for the user {host.user}."
64
+ else:
65
+ msg += "No password or SSH key was provided. Ensure your current host has SSH access to the target host."
66
+ raise cappa.Exit(msg, code=1) from e
67
+ except (UnexpectedExit, NoValidConnectionsError) as e:
68
+ raise cappa.Exit(str(e), code=1) from e
69
+ except SSHException as e:
70
+ raise cappa.Exit(
71
+ f"{e}, possible causes: incorrect user, or either you or the server may be offline",
72
+ code=1,
73
+ ) from e
74
+ finally:
75
+ conn.close()