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.
- fujin/__init__.py +0 -2
- fujin/__main__.py +71 -0
- fujin/commands/__init__.py +2 -0
- fujin/commands/_base.py +76 -0
- fujin/commands/app.py +138 -0
- fujin/commands/config.py +65 -0
- fujin/commands/deploy.py +116 -0
- fujin/commands/docs.py +16 -0
- fujin/commands/down.py +48 -0
- fujin/commands/init.py +82 -0
- fujin/commands/proxy.py +71 -0
- fujin/commands/prune.py +42 -0
- fujin/commands/redeploy.py +48 -0
- fujin/commands/rollback.py +49 -0
- fujin/commands/secrets.py +11 -0
- fujin/commands/server.py +92 -0
- fujin/commands/up.py +15 -0
- fujin/config.py +290 -0
- fujin/connection.py +75 -0
- fujin/errors.py +5 -0
- fujin/hooks.py +55 -0
- fujin/process_managers/__init__.py +40 -0
- fujin/process_managers/systemd.py +155 -0
- fujin/proxies/__init__.py +35 -0
- fujin/proxies/caddy.py +214 -0
- fujin/proxies/dummy.py +29 -0
- fujin/proxies/nginx.py +132 -0
- fujin/templates/simple.service +14 -0
- fujin/templates/web.service +25 -0
- fujin/templates/web.socket +11 -0
- fujin_cli-0.3.0.dist-info/METADATA +66 -0
- fujin_cli-0.3.0.dist-info/RECORD +35 -0
- fujin_cli-0.3.0.dist-info/entry_points.txt +2 -0
- fujin/fabfile.py +0 -202
- fujin/utils.py +0 -60
- fujin_cli-0.1.0.dist-info/METADATA +0 -41
- fujin_cli-0.1.0.dist-info/RECORD +0 -8
- fujin_cli-0.1.0.dist-info/entry_points.txt +0 -2
- {fujin_cli-0.1.0.dist-info → fujin_cli-0.3.0.dist-info}/WHEEL +0 -0
- {fujin_cli-0.1.0.dist-info → fujin_cli-0.3.0.dist-info}/licenses/LICENSE.txt +0 -0
fujin/commands/proxy.py
ADDED
|
@@ -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
|
+
)
|
fujin/commands/prune.py
ADDED
|
@@ -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
|
+
)
|
fujin/commands/server.py
ADDED
|
@@ -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()
|