fujin-cli 0.1.0__py3-none-any.whl → 0.2.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 CHANGED
@@ -1,2 +0,0 @@
1
- def main() -> None:
2
- print("Hello from fujin!")
fujin/__main__.py ADDED
@@ -0,0 +1,53 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ import cappa
5
+
6
+ from fujin.commands.app import App
7
+ from fujin.commands.config import ConfigCMD
8
+ from fujin.commands.deploy import Deploy
9
+ from fujin.commands.down import Down
10
+ from fujin.commands.redeploy import Redeploy
11
+ from fujin.commands.server import Server
12
+ from fujin.commands.up import Up
13
+
14
+ if sys.version_info >= (3, 11):
15
+ import tomllib
16
+ else:
17
+ import tomli as tomllib
18
+
19
+
20
+ @cappa.command(help="Deployment of python web apps in a breeze :)")
21
+ class Fujin:
22
+ subcommands: cappa.Subcommands[
23
+ Up | Deploy | Redeploy | App | Server | ConfigCMD | Down
24
+ ]
25
+
26
+
27
+ def main():
28
+ # install(show_locals=True)
29
+ alias_cmd = _parse_aliases()
30
+ if alias_cmd:
31
+ cappa.invoke(Fujin, argv=alias_cmd)
32
+ else:
33
+ cappa.invoke(Fujin)
34
+
35
+
36
+ def _parse_aliases() -> list[str] | None:
37
+ fujin_toml = Path("fujin.toml")
38
+ if not fujin_toml.exists():
39
+ return
40
+ data = tomllib.loads(fujin_toml.read_text())
41
+ aliases = data.get("aliases")
42
+ if not aliases:
43
+ return
44
+ if len(sys.argv) == 1:
45
+ return
46
+ if sys.argv[1] not in aliases:
47
+ return
48
+ extra_args = sys.argv[2:] if len(sys.argv) > 2 else []
49
+ return [*aliases.get(sys.argv[1]).split(), *extra_args]
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
@@ -0,0 +1,3 @@
1
+ from ._base import BaseCommand # noqa
2
+
3
+ from ._base import AppCommand # noqa
@@ -0,0 +1,83 @@
1
+ import importlib
2
+ from dataclasses import dataclass
3
+ from functools import cached_property
4
+ from typing import Annotated
5
+
6
+ import cappa
7
+
8
+ from fujin.config import Config
9
+ from fujin.errors import ImproperlyConfiguredError
10
+ from fujin.hooks import HookManager
11
+ from fujin.host import Host
12
+ from fujin.process_managers import ProcessManager
13
+ from fujin.proxies import WebProxy
14
+
15
+
16
+ @dataclass
17
+ class BaseCommand:
18
+ @cached_property
19
+ def config(self) -> Config:
20
+ return Config.read()
21
+
22
+ @cached_property
23
+ def stdout(self) -> cappa.Output:
24
+ return cappa.Output()
25
+
26
+
27
+ @dataclass
28
+ class AppCommand(BaseCommand):
29
+ """
30
+ A command that provides access to the current host and allows interaction with it,
31
+ including configuring the web proxy and managing systemd services.
32
+ """
33
+
34
+ _host: Annotated[str | None, cappa.Arg(long="--host", value_name="HOST")]
35
+
36
+ # TODO: add info / details command that will list all services with their current status, if they are installed or running or stopped
37
+
38
+ @cached_property
39
+ def host(self) -> Host:
40
+ if not self._host:
41
+ host_config = next(
42
+ (hc for hc in self.config.hosts.values() if hc.default), None
43
+ )
44
+ if not host_config:
45
+ raise ImproperlyConfiguredError(
46
+ "No default host has been configured, either pass --host or set the default in your fujin.toml file"
47
+ )
48
+ else:
49
+ host_config = next(
50
+ (
51
+ hc
52
+ for name, hc in self.config.hosts.items()
53
+ if self._host in [name, hc.ip]
54
+ ),
55
+ None,
56
+ )
57
+ if not host_config:
58
+ raise cappa.Exit(f"Host {self._host} does not exist", code=1)
59
+ return Host(config=host_config)
60
+
61
+ @cached_property
62
+ def web_proxy(self) -> WebProxy:
63
+ module = importlib.import_module(self.config.webserver.type)
64
+ try:
65
+ return getattr(module, "WebProxy")(host=self.host, config=self.config)
66
+ except KeyError as e:
67
+ raise ImproperlyConfiguredError(
68
+ f"Missing WebProxy class in {self.config.webserver.type}"
69
+ ) from e
70
+
71
+ @cached_property
72
+ def process_manager(self) -> ProcessManager:
73
+ module = importlib.import_module(self.config.process_manager)
74
+ try:
75
+ return getattr(module, "ProcessManager")(host=self.host, config=self.config)
76
+ except KeyError as e:
77
+ raise ImproperlyConfiguredError(
78
+ f"Missing ProcessManager class in {self.config.process_manager}"
79
+ ) from e
80
+
81
+ @cached_property
82
+ def hook_manager(self) -> HookManager:
83
+ return HookManager(host=self.host, config=self.config)
fujin/commands/app.py ADDED
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ import cappa
6
+
7
+ from fujin.commands import AppCommand
8
+
9
+
10
+ @cappa.command(help="Run application-related tasks")
11
+ class App(AppCommand):
12
+
13
+ @cappa.command(help="Run an arbitrary command via the application binary")
14
+ def exec(
15
+ self,
16
+ command: str,
17
+ interactive: Annotated[bool, cappa.Arg(default=False, short="-i")],
18
+ ):
19
+ with self.host.cd_project_dir(self.config.app):
20
+ if interactive:
21
+ self.host.run(f"{self.config.app_bin} {command}", pty=interactive)
22
+ else:
23
+ result = self.host.run(f"{self.config.app_bin} {command}", hide=True)
24
+ self.stdout.output(result)
25
+
26
+ @cappa.command(
27
+ help="Start the specified service or all services if no name is provided"
28
+ )
29
+ def start(
30
+ self,
31
+ name: Annotated[
32
+ str | None, cappa.Arg(help="Service name, no value means all")
33
+ ] = None,
34
+ ):
35
+ self.process_manager.start_services(name)
36
+ msg = f"{name} Service" if name else "All Services"
37
+ self.stdout.output(f"[green]{msg} started successfully![/green]")
38
+
39
+ @cappa.command(
40
+ help="Restart the specified service or all services if no name is provided"
41
+ )
42
+ def restart(
43
+ self,
44
+ name: Annotated[
45
+ str | None, cappa.Arg(help="Service name, no value means all")
46
+ ] = None,
47
+ ):
48
+ self.process_manager.restart_services(name)
49
+ msg = f"{name} Service" if name else "All Services"
50
+ self.stdout.output(f"[green]{msg} restarted successfully![/green]")
51
+
52
+ @cappa.command(
53
+ help="Stop the specified service or all services if no name is provided"
54
+ )
55
+ def stop(
56
+ self,
57
+ name: Annotated[
58
+ str | None, cappa.Arg(help="Service name, no value means all")
59
+ ] = None,
60
+ ):
61
+ self.process_manager.stop_services(name)
62
+ msg = f"{name} Service" if name else "All Services"
63
+ self.stdout.output(f"[green]{msg} stopped successfully![/green]")
64
+
65
+ @cappa.command(help="Show logs for the specified service")
66
+ def logs(
67
+ self, name: Annotated[str, cappa.Arg(help="Service name")], follow: bool = False
68
+ ):
69
+ # TODO: flash out this more
70
+ self.process_manager.service_logs(name=name, follow=follow)
@@ -0,0 +1,174 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import cappa
7
+ import tomli_w
8
+ from rich.console import Console
9
+ from rich.markdown import Markdown
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+
13
+ from fujin.commands import BaseCommand
14
+ from fujin.config import tomllib
15
+
16
+
17
+ @cappa.command(name="config", help="Manage application configuration")
18
+ class ConfigCMD(BaseCommand):
19
+
20
+ @cappa.command(help="Display the parsed configuration")
21
+ def show(self):
22
+ console = Console()
23
+
24
+ general_config = {
25
+ "app": self.config.app,
26
+ "app_bin": self.config.app_bin,
27
+ "version": self.config.version,
28
+ "python_version": self.config.python_version,
29
+ "build_command": self.config.build_command,
30
+ "distfile": self.config.distfile,
31
+ "requirements": self.config.requirements,
32
+ "webserver": f"{{ upstream = '{self.config.webserver.upstream}', type = '{self.config.webserver.type}' }}",
33
+ }
34
+ formatted_text = "\n".join(
35
+ f"[bold green]{key}:[/bold green] {value}"
36
+ for key, value in general_config.items()
37
+ )
38
+ console.print(
39
+ Panel(
40
+ formatted_text,
41
+ title="General Configuration",
42
+ border_style="green",
43
+ width=100,
44
+ )
45
+ )
46
+
47
+ # Hosts Table with headers and each dictionary on its own line
48
+ hosts_table = Table(title="Hosts", header_style="bold cyan")
49
+ hosts_table.add_column("Host", style="dim")
50
+ hosts_table.add_column("ip")
51
+ hosts_table.add_column("domain_name")
52
+ hosts_table.add_column("user")
53
+ hosts_table.add_column("password_env")
54
+ hosts_table.add_column("projects_dr")
55
+ hosts_table.add_column("ssh_port")
56
+ hosts_table.add_column("key_filename")
57
+ hosts_table.add_column("envfile")
58
+ hosts_table.add_column("primary", justify="center")
59
+
60
+ for host_name, host in self.config.hosts.items():
61
+ host_dict = host.to_dict()
62
+ hosts_table.add_row(
63
+ host_name,
64
+ host_dict["ip"],
65
+ host_dict["domain_name"],
66
+ host_dict["user"],
67
+ str(host_dict["password_env"] or "N/A"),
68
+ host_dict["projects_dir"],
69
+ str(host_dict["ssh_port"]),
70
+ str(host_dict["_key_filename"] or "N/A"),
71
+ host_dict["_envfile"],
72
+ "[green]Yes[/green]" if host_dict["default"] else "[red]No[/red]",
73
+ )
74
+
75
+ console.print(hosts_table)
76
+
77
+ # Processes Table with headers and each dictionary on its own line
78
+ processes_table = Table(title="Processes", header_style="bold cyan")
79
+ processes_table.add_column("Name", style="dim")
80
+ processes_table.add_column("Command")
81
+ for name, command in self.config.processes.items():
82
+ processes_table.add_row(name, command)
83
+ console.print(processes_table)
84
+
85
+ aliases_table = Table(title="Aliases", header_style="bold cyan")
86
+ aliases_table.add_column("Alias", style="dim")
87
+ aliases_table.add_column("Command")
88
+ for alias, command in self.config.aliases.items():
89
+ aliases_table.add_row(alias, command)
90
+
91
+ console.print(aliases_table)
92
+
93
+ @cappa.command(help="Generate a sample configuration file")
94
+ def init(
95
+ self,
96
+ profile: Annotated[
97
+ str, cappa.Arg(choices=["simple", "falco"], short="-p", long="--profile")
98
+ ] = "simple",
99
+ ):
100
+ fujin_toml = Path("fujin.toml")
101
+ if fujin_toml.exists():
102
+ raise cappa.Exit("fujin.toml file already exists", code=1)
103
+ profile_to_func = {"simple": simple_config, "falco": falco_config}
104
+ config = profile_to_func[profile]()
105
+ fujin_toml.write_text(tomli_w.dumps(config))
106
+ self.stdout.output(
107
+ "[green]Sample configuration file generated successfully![/green]"
108
+ )
109
+
110
+ @cappa.command(help="Config documentation")
111
+ def docs(self):
112
+ self.stdout.output(Markdown(docs))
113
+
114
+
115
+ def simple_config() -> dict:
116
+ app = Path().resolve().stem.replace("-", "_").replace(" ", "_").lower()
117
+
118
+ config = {
119
+ "app": app,
120
+ "version": "0.1.0",
121
+ "build_command": "uv build",
122
+ "distfile": f"dist/{app}-{{version}}-py3-none-any.whl",
123
+ "webserver": {
124
+ "upstream": "localhost:8000",
125
+ "type": "fujin.proxies.caddy",
126
+ },
127
+ "hooks": {"pre_deploy": f".venv/bin/{app} migrate"},
128
+ "processes": {"web": f".venv/bin/gunicorn {app}.wsgi:app --bind 0.0.0.0:8000"},
129
+ "aliases": {"shell": "server exec -i bash"},
130
+ "hosts": {
131
+ "primary": {
132
+ "ip": "127.0.0.1",
133
+ "user": "root",
134
+ "domain_name": f"{app}.com",
135
+ "envfile": ".env.prod",
136
+ "default": True,
137
+ }
138
+ },
139
+ }
140
+ if not Path(".python-version").exists():
141
+ config["python_version"] = "3.12"
142
+ pyproject_toml = Path("pyproject.toml")
143
+ if pyproject_toml.exists():
144
+ pyproject = tomllib.loads(pyproject_toml.read_text())
145
+ config["app"] = pyproject.get("project", {}).get("name", app)
146
+ if pyproject.get("project", {}).get("version"):
147
+ # fujin will read the version itself from the pyproject
148
+ config.pop("version")
149
+ return config
150
+
151
+
152
+ def falco_config() -> dict:
153
+ config = simple_config()
154
+ config.update(
155
+ {
156
+ "hooks": {"pre_deploy": f".venv/bin/{config['app']} setup"},
157
+ "processes": {
158
+ "web": f".venv/bin/{config['app']} prodserver",
159
+ "worker": f".venv/bin/{config['app']} qcluster",
160
+ },
161
+ "aliases": {
162
+ "console": "app exec -i shell_plus",
163
+ "dbconsole": "app exec -i dbshell",
164
+ "print_settings": "app exec print_settings --format=pprint",
165
+ "shell": "server exec -i bash",
166
+ },
167
+ }
168
+ )
169
+ return config
170
+
171
+
172
+ docs = """
173
+ # Fujin Configuration
174
+ """
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+
5
+ import cappa
6
+
7
+ from fujin.commands import AppCommand
8
+
9
+
10
+ @cappa.command(
11
+ help="Deploy the project by building, transferring files, installing, and configuring services"
12
+ )
13
+ class Deploy(AppCommand):
14
+
15
+ def __call__(self):
16
+ try:
17
+ subprocess.run(self.config.build_command.split(), check=True)
18
+ except subprocess.CalledProcessError as e:
19
+ raise cappa.Exit(f"build command failed: {e}", code=1) from e
20
+
21
+ self.host.make_project_dir(project_name=self.config.app)
22
+ self.transfer_files()
23
+ self.install_project()
24
+ self.hook_manager.pre_deploy()
25
+
26
+ self.process_manager.install_services()
27
+ self.process_manager.reload_configuration()
28
+ self.process_manager.restart_services()
29
+
30
+ self.web_proxy.setup()
31
+ self.stdout.output("[green]Project deployment completed successfully![/green]")
32
+ self.stdout.output(
33
+ f"[blue]Access the deployed project at: https://{self.host.config.domain_name}[/blue]"
34
+ )
35
+
36
+ def transfer_files(self):
37
+ if not self.host.config.envfile.exists():
38
+ raise cappa.Exit(f"{self.host.config.envfile} not found", code=1)
39
+
40
+ if not self.config.requirements.exists():
41
+ raise cappa.Exit(f"{self.config.requirements} not found", code=1)
42
+ project_dir = self.host.project_dir(self.config.app)
43
+ self.host.put(str(self.config.requirements), f"{project_dir}/requirements.txt")
44
+ self.host.put(str(self.host.config.envfile), f"{project_dir}/.env")
45
+ self.host.put(
46
+ str(self.config.distfile), f"{project_dir}/{self.config.distfile.name}"
47
+ )
48
+ self.host.run(f"echo {self.config.python_version} > .python-version")
49
+
50
+ def install_project(self):
51
+ with self.host.cd_project_dir(self.config.app):
52
+ self.host.run_uv("venv")
53
+ self.host.run_uv("pip install -r requirements.txt")
54
+ self.host.run_uv(f"pip install {self.config.distfile.name}")
fujin/commands/down.py ADDED
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import cappa
6
+ from rich.prompt import Prompt
7
+
8
+ from fujin.commands import AppCommand
9
+
10
+
11
+ @cappa.command(
12
+ help="Tear down the project by stopping services and cleaning up resources"
13
+ )
14
+ @dataclass
15
+ class Down(AppCommand):
16
+
17
+ def __call__(self):
18
+ confirm = Prompt.ask(
19
+ f"""[red]You are about to delete all project files, stop all services, and remove all configurations on the host {self.host} for the project {self.config.app}. Any assets in your project folder will be lost (sqlite not in there ?). Are you sure you want to proceed? This action is irreversible.[/red]""",
20
+ choices=["no", "yes"],
21
+ default="no",
22
+ )
23
+ if confirm == "no":
24
+ return
25
+ project_dir = self.host.project_dir(self.config.app)
26
+ self.host.run(f"rm -rf {project_dir}")
27
+ self.web_proxy.teardown()
28
+ self.process_manager.uninstall_services()
29
+ self.process_manager.reload_configuration()
30
+ self.stdout.output("[green]Project teardown completed successfully![/green]")
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ import cappa
4
+
5
+ from fujin.commands import AppCommand
6
+
7
+ from .deploy import Deploy
8
+
9
+
10
+ @cappa.command(help="Redeploy the application to apply code and environment changes")
11
+ class Redeploy(AppCommand):
12
+
13
+ def __call__(self):
14
+ deploy = Deploy(_host=self._host)
15
+ deploy.transfer_files()
16
+ deploy.install_project()
17
+ self.hook_manager.pre_deploy()
18
+ self.process_manager.restart_services()
19
+ self.stdout.output("[green]Redeployment completed successfully![/green]")
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ import cappa
6
+
7
+ from fujin.commands import AppCommand
8
+
9
+
10
+ @cappa.command(help="Manage server operations")
11
+ class Server(AppCommand):
12
+
13
+ @cappa.command(help="Display information about the host system")
14
+ def info(self):
15
+ self.stdout.output(self.host.sudo("cat /etc/os-release", hide="out"))
16
+
17
+ @cappa.command(help="Setup uv, web proxy, and install necessary dependencies")
18
+ def bootstrap(self):
19
+ self.host.sudo("apt update")
20
+ self.host.sudo("apt upgrade -y")
21
+ self.host.sudo("apt install -y sqlite3 curl")
22
+ result = self.host.run("source $HOME/.cargo/env && command -v uv", warn=True)
23
+ if not result.ok:
24
+ self.host.run("curl -LsSf https://astral.sh/uv/install.sh | sh")
25
+ self.host.run_uv("tool update-shell")
26
+ self.web_proxy.install()
27
+ self.stdout.output("[green]Server bootstrap completed successfully![/green]")
28
+
29
+ @cappa.command(
30
+ help="Execute an arbitrary command on the server, optionally in interactive mode"
31
+ )
32
+ def exec(
33
+ self,
34
+ command: str,
35
+ interactive: Annotated[bool, cappa.Arg(default=False, short="-i")],
36
+ ):
37
+ if interactive:
38
+ self.host.run(command, pty=interactive)
39
+ else:
40
+ result = self.host.run(command, hide=True)
41
+ self.stdout.output(result)
42
+
43
+ @cappa.command(
44
+ name="create-user", help="Create a new user with sudo and ssh access"
45
+ )
46
+ def create_user(self, name: str):
47
+ # TODO not working right now, ssh key not working
48
+ self.host.sudo(f"adduser --disabled-password --gecos '' {name}")
49
+ self.host.sudo(f"mkdir -p /home/{name}/.ssh")
50
+ self.host.sudo(f"cp ~/.ssh/authorized_keys /home/{name}/.ssh/")
51
+ self.host.sudo(f"chown -R {name}:{name} /home/{name}/.ssh")
52
+ self.host.sudo(f"chmod 700 /home/{name}/.ssh")
53
+ self.host.sudo(f"chmod 600 /home/{name}/.ssh/authorized_keys")
54
+ self.host.sudo(
55
+ f"echo '{name} ALL=(ALL) NOPASSWD:ALL' | sudo tee -a /etc/sudoers"
56
+ )
57
+ self.stdout.output(f"[green]New user {name} created successfully![/green]")
fujin/commands/up.py ADDED
@@ -0,0 +1,16 @@
1
+ import cappa
2
+
3
+ from fujin.commands import AppCommand
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(AppCommand):
10
+
11
+ def __call__(self):
12
+ Server(_host=self._host).bootstrap()
13
+ Deploy(_host=self._host)()
14
+ self.stdout.output(
15
+ "[green]Server bootstrapped and application deployed successfully![/green]"
16
+ )