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/__init__.py
CHANGED
fujin/__main__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import cappa
|
|
6
|
+
|
|
7
|
+
from fujin.commands.app import App
|
|
8
|
+
from fujin.commands.config import ConfigCMD
|
|
9
|
+
from fujin.commands.deploy import Deploy
|
|
10
|
+
from fujin.commands.docs import Docs
|
|
11
|
+
from fujin.commands.down import Down
|
|
12
|
+
from fujin.commands.init import Init
|
|
13
|
+
from fujin.commands.proxy import Proxy
|
|
14
|
+
from fujin.commands.prune import Prune
|
|
15
|
+
from fujin.commands.redeploy import Redeploy
|
|
16
|
+
from fujin.commands.rollback import Rollback
|
|
17
|
+
from fujin.commands.server import Server
|
|
18
|
+
from fujin.commands.up import Up
|
|
19
|
+
|
|
20
|
+
if sys.version_info >= (3, 11):
|
|
21
|
+
import tomllib
|
|
22
|
+
else:
|
|
23
|
+
import tomli as tomllib
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@cappa.command(help="Deployment of python web apps in a breeze :)")
|
|
27
|
+
class Fujin:
|
|
28
|
+
subcommands: cappa.Subcommands[
|
|
29
|
+
Init
|
|
30
|
+
| Up
|
|
31
|
+
| Deploy
|
|
32
|
+
| Redeploy
|
|
33
|
+
| App
|
|
34
|
+
| Server
|
|
35
|
+
| Proxy
|
|
36
|
+
| ConfigCMD
|
|
37
|
+
| Docs
|
|
38
|
+
| Down
|
|
39
|
+
| Rollback
|
|
40
|
+
| Prune
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main():
|
|
45
|
+
alias_cmd = _parse_aliases()
|
|
46
|
+
if alias_cmd:
|
|
47
|
+
cappa.invoke(Fujin, argv=alias_cmd)
|
|
48
|
+
else:
|
|
49
|
+
cappa.invoke(Fujin)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_aliases() -> list[str] | None:
|
|
53
|
+
fujin_toml = Path("fujin.toml")
|
|
54
|
+
if not fujin_toml.exists():
|
|
55
|
+
return
|
|
56
|
+
data = tomllib.loads(fujin_toml.read_text())
|
|
57
|
+
aliases: dict[str, str] = data.get("aliases")
|
|
58
|
+
if not aliases:
|
|
59
|
+
return
|
|
60
|
+
if len(sys.argv) == 1:
|
|
61
|
+
return
|
|
62
|
+
if sys.argv[1] not in aliases:
|
|
63
|
+
return
|
|
64
|
+
extra_args = sys.argv[2:] if len(sys.argv) > 2 else []
|
|
65
|
+
aliased_cmd = aliases.get(sys.argv[1])
|
|
66
|
+
subcommand, args = aliased_cmd.split(" ", 1)
|
|
67
|
+
return [subcommand, *extra_args, *shlex.split(args, posix=True)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
main()
|
fujin/commands/_base.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from functools import cached_property
|
|
5
|
+
|
|
6
|
+
import cappa
|
|
7
|
+
|
|
8
|
+
from fujin.config import Config
|
|
9
|
+
from fujin.connection import host_connection, Connection
|
|
10
|
+
from fujin.errors import ImproperlyConfiguredError
|
|
11
|
+
from fujin.hooks import HookManager
|
|
12
|
+
from fujin.process_managers import ProcessManager
|
|
13
|
+
from fujin.proxies import WebProxy
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class BaseCommand:
|
|
18
|
+
"""
|
|
19
|
+
A command that provides access to the host config and provide a connection to interact with it,
|
|
20
|
+
including configuring the web proxy and managing systemd services.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@cached_property
|
|
24
|
+
def config(self) -> Config:
|
|
25
|
+
return Config.read()
|
|
26
|
+
|
|
27
|
+
@cached_property
|
|
28
|
+
def stdout(self) -> cappa.Output:
|
|
29
|
+
return cappa.Output()
|
|
30
|
+
|
|
31
|
+
@cached_property
|
|
32
|
+
def app_dir(self) -> str:
|
|
33
|
+
return self.config.host.get_app_dir(app_name=self.config.app_name)
|
|
34
|
+
|
|
35
|
+
@contextmanager
|
|
36
|
+
def connection(self):
|
|
37
|
+
with host_connection(host=self.config.host) as conn:
|
|
38
|
+
yield conn
|
|
39
|
+
|
|
40
|
+
@contextmanager
|
|
41
|
+
def app_environment(self) -> Connection:
|
|
42
|
+
with self.connection() as conn:
|
|
43
|
+
with conn.cd(self.app_dir):
|
|
44
|
+
with conn.prefix("source .appenv"):
|
|
45
|
+
yield conn
|
|
46
|
+
|
|
47
|
+
@cached_property
|
|
48
|
+
def web_proxy_class(self) -> type[WebProxy]:
|
|
49
|
+
module = importlib.import_module(self.config.webserver.type)
|
|
50
|
+
try:
|
|
51
|
+
return getattr(module, "WebProxy")
|
|
52
|
+
except KeyError as e:
|
|
53
|
+
raise ImproperlyConfiguredError(
|
|
54
|
+
f"Missing WebProxy class in {self.config.webserver.type}"
|
|
55
|
+
) from e
|
|
56
|
+
|
|
57
|
+
def create_web_proxy(self, conn: Connection) -> WebProxy:
|
|
58
|
+
return self.web_proxy_class.create(conn=conn, config=self.config)
|
|
59
|
+
|
|
60
|
+
@cached_property
|
|
61
|
+
def process_manager_class(self) -> type[ProcessManager]:
|
|
62
|
+
module = importlib.import_module(self.config.process_manager)
|
|
63
|
+
try:
|
|
64
|
+
return getattr(module, "ProcessManager")
|
|
65
|
+
except KeyError as e:
|
|
66
|
+
raise ImproperlyConfiguredError(
|
|
67
|
+
f"Missing ProcessManager class in {self.config.process_manager}"
|
|
68
|
+
) from e
|
|
69
|
+
|
|
70
|
+
def create_process_manager(self, conn: Connection) -> ProcessManager:
|
|
71
|
+
return self.process_manager_class.create(conn=conn, config=self.config)
|
|
72
|
+
|
|
73
|
+
def create_hook_manager(self, conn: Connection) -> HookManager:
|
|
74
|
+
return HookManager(
|
|
75
|
+
conn=conn, hooks=self.config.hooks, app_name=self.config.app_name
|
|
76
|
+
)
|
fujin/commands/app.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import cappa
|
|
6
|
+
|
|
7
|
+
from fujin.commands import BaseCommand
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@cappa.command(help="Run application-related tasks")
|
|
11
|
+
class App(BaseCommand):
|
|
12
|
+
@cappa.command(help="Display information about the application")
|
|
13
|
+
def info(self):
|
|
14
|
+
with self.app_environment() as conn:
|
|
15
|
+
remote_version = (
|
|
16
|
+
conn.run("head -n 1 .versions", warn=True, hide=True).stdout.strip()
|
|
17
|
+
or "N/A"
|
|
18
|
+
)
|
|
19
|
+
rollback_targets = conn.run(
|
|
20
|
+
"sed -n '2,$p' .versions", warn=True, hide=True
|
|
21
|
+
).stdout.strip()
|
|
22
|
+
infos = {
|
|
23
|
+
"app_name": self.config.app_name,
|
|
24
|
+
"app_dir": self.app_dir,
|
|
25
|
+
"app_bin": self.config.app_bin,
|
|
26
|
+
"local_version": self.config.version,
|
|
27
|
+
"remote_version": remote_version,
|
|
28
|
+
"python_version": self.config.python_version,
|
|
29
|
+
"rollback_targets": ", ".join(rollback_targets.split("\n"))
|
|
30
|
+
if rollback_targets
|
|
31
|
+
else "N/A",
|
|
32
|
+
}
|
|
33
|
+
pm = self.create_process_manager(conn)
|
|
34
|
+
services: dict[str, bool] = pm.is_active()
|
|
35
|
+
|
|
36
|
+
infos_text = "\n".join(f"{key}: {value}" for key, value in infos.items())
|
|
37
|
+
from rich.table import Table
|
|
38
|
+
|
|
39
|
+
table = Table(title="", header_style="bold cyan")
|
|
40
|
+
table.add_column("Service", style="")
|
|
41
|
+
table.add_column("Running?")
|
|
42
|
+
for service, is_active in services.items():
|
|
43
|
+
table.add_row(
|
|
44
|
+
service,
|
|
45
|
+
"[bold green]Yes[/bold green]"
|
|
46
|
+
if is_active
|
|
47
|
+
else "[bold red]No[/bold red]",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
self.stdout.output(infos_text)
|
|
51
|
+
self.stdout.output(table)
|
|
52
|
+
|
|
53
|
+
@cappa.command(help="Run an arbitrary command via the application binary")
|
|
54
|
+
def exec(
|
|
55
|
+
self,
|
|
56
|
+
command: str,
|
|
57
|
+
interactive: Annotated[bool, cappa.Arg(default=False, short="-i")],
|
|
58
|
+
):
|
|
59
|
+
with self.app_environment() as conn:
|
|
60
|
+
if interactive:
|
|
61
|
+
conn.run(f"{self.config.app_bin} {command}", pty=interactive, warn=True)
|
|
62
|
+
else:
|
|
63
|
+
self.stdout.output(
|
|
64
|
+
conn.run(f"{self.config.app_bin} {command}", hide=True).stdout
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@cappa.command(
|
|
68
|
+
help="Start the specified service or all services if no name is provided"
|
|
69
|
+
)
|
|
70
|
+
def start(
|
|
71
|
+
self,
|
|
72
|
+
name: Annotated[
|
|
73
|
+
str | None, cappa.Arg(help="Service name, no value means all")
|
|
74
|
+
] = None,
|
|
75
|
+
):
|
|
76
|
+
with self.app_environment() as conn:
|
|
77
|
+
self.create_process_manager(conn).start_services(name)
|
|
78
|
+
msg = f"{name} Service" if name else "All Services"
|
|
79
|
+
self.stdout.output(f"[green]{msg} started successfully![/green]")
|
|
80
|
+
|
|
81
|
+
@cappa.command(
|
|
82
|
+
help="Restart the specified service or all services if no name is provided"
|
|
83
|
+
)
|
|
84
|
+
def restart(
|
|
85
|
+
self,
|
|
86
|
+
name: Annotated[
|
|
87
|
+
str | None, cappa.Arg(help="Service name, no value means all")
|
|
88
|
+
] = None,
|
|
89
|
+
):
|
|
90
|
+
with self.app_environment() as conn:
|
|
91
|
+
self.create_process_manager(conn).restart_services(name)
|
|
92
|
+
msg = f"{name} Service" if name else "All Services"
|
|
93
|
+
self.stdout.output(f"[green]{msg} restarted successfully![/green]")
|
|
94
|
+
|
|
95
|
+
@cappa.command(
|
|
96
|
+
help="Stop the specified service or all services if no name is provided"
|
|
97
|
+
)
|
|
98
|
+
def stop(
|
|
99
|
+
self,
|
|
100
|
+
name: Annotated[
|
|
101
|
+
str | None, cappa.Arg(help="Service name, no value means all")
|
|
102
|
+
] = None,
|
|
103
|
+
):
|
|
104
|
+
with self.app_environment() as conn:
|
|
105
|
+
self.create_process_manager(conn).stop_services(name)
|
|
106
|
+
msg = f"{name} Service" if name else "All Services"
|
|
107
|
+
self.stdout.output(f"[green]{msg} stopped successfully![/green]")
|
|
108
|
+
|
|
109
|
+
@cappa.command(help="Show logs for the specified service")
|
|
110
|
+
def logs(
|
|
111
|
+
self, name: Annotated[str, cappa.Arg(help="Service name")], follow: bool = False
|
|
112
|
+
):
|
|
113
|
+
# TODO: flash out this more
|
|
114
|
+
with self.app_environment() as conn:
|
|
115
|
+
self.create_process_manager(conn).service_logs(name=name, follow=follow)
|
|
116
|
+
|
|
117
|
+
@cappa.command(
|
|
118
|
+
name="export-config",
|
|
119
|
+
help="Export the service configuration files locally to the .fujin directory",
|
|
120
|
+
)
|
|
121
|
+
def export_config(
|
|
122
|
+
self,
|
|
123
|
+
overwrite: Annotated[
|
|
124
|
+
bool, cappa.Arg(help="overwrite any existing config file")
|
|
125
|
+
] = False,
|
|
126
|
+
):
|
|
127
|
+
with self.connection() as conn:
|
|
128
|
+
for filename, content in self.create_process_manager(
|
|
129
|
+
conn
|
|
130
|
+
).get_configuration_files(ignore_local=True):
|
|
131
|
+
local_config = self.config.local_config_dir / filename
|
|
132
|
+
if local_config.exists() and not overwrite:
|
|
133
|
+
self.stdout.output(
|
|
134
|
+
f"[blue]Skipping {filename}, file already exists. Use --overwrite to replace it.[/blue]"
|
|
135
|
+
)
|
|
136
|
+
continue
|
|
137
|
+
local_config.write_text(content)
|
|
138
|
+
self.stdout.output(f"[green]{filename} exported successfully![/green]")
|
fujin/commands/config.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import cappa
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from fujin.commands import BaseCommand
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@cappa.command(name="config", help="Display your current configuration")
|
|
12
|
+
class ConfigCMD(BaseCommand):
|
|
13
|
+
def __call__(self):
|
|
14
|
+
console = Console()
|
|
15
|
+
general_config = {
|
|
16
|
+
"app": self.config.app_name,
|
|
17
|
+
"app_bin": self.config.app_bin,
|
|
18
|
+
"version": self.config.version,
|
|
19
|
+
"python_version": self.config.python_version,
|
|
20
|
+
"build_command": self.config.build_command,
|
|
21
|
+
"release_command": self.config.release_command,
|
|
22
|
+
"distfile": self.config.distfile,
|
|
23
|
+
"requirements": self.config.requirements,
|
|
24
|
+
"webserver": f"{{ upstream = '{self.config.webserver.upstream}', type = '{self.config.webserver.type}' }}",
|
|
25
|
+
}
|
|
26
|
+
general_config_text = "\n".join(
|
|
27
|
+
f"[bold green]{key}:[/bold green] {value}"
|
|
28
|
+
for key, value in general_config.items()
|
|
29
|
+
)
|
|
30
|
+
console.print(
|
|
31
|
+
Panel(
|
|
32
|
+
general_config_text,
|
|
33
|
+
title="General Configuration",
|
|
34
|
+
border_style="green",
|
|
35
|
+
width=100,
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
host_config_text = "\n".join(
|
|
40
|
+
f"[dim]{key}:[/dim] {value}"
|
|
41
|
+
for key, value in self.config.host.to_dict().items()
|
|
42
|
+
)
|
|
43
|
+
console.print(
|
|
44
|
+
Panel(
|
|
45
|
+
host_config_text,
|
|
46
|
+
title="Host Configuration",
|
|
47
|
+
width=100,
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Processes Table with headers and each dictionary on its own line
|
|
52
|
+
processes_table = Table(title="Processes", header_style="bold cyan")
|
|
53
|
+
processes_table.add_column("Name", style="dim")
|
|
54
|
+
processes_table.add_column("Command")
|
|
55
|
+
for name, command in self.config.processes.items():
|
|
56
|
+
processes_table.add_row(name, command)
|
|
57
|
+
console.print(processes_table)
|
|
58
|
+
|
|
59
|
+
aliases_table = Table(title="Aliases", header_style="bold cyan")
|
|
60
|
+
aliases_table.add_column("Alias", style="dim")
|
|
61
|
+
aliases_table.add_column("Command")
|
|
62
|
+
for alias, command in self.config.aliases.items():
|
|
63
|
+
aliases_table.add_row(alias, command)
|
|
64
|
+
|
|
65
|
+
console.print(aliases_table)
|
fujin/commands/deploy.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
import cappa
|
|
6
|
+
|
|
7
|
+
from fujin.commands import BaseCommand
|
|
8
|
+
from fujin.connection import Connection
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@cappa.command(
|
|
12
|
+
help="Deploy the project by building, transferring files, installing, and configuring services"
|
|
13
|
+
)
|
|
14
|
+
class Deploy(BaseCommand):
|
|
15
|
+
def __call__(self):
|
|
16
|
+
self.build_app()
|
|
17
|
+
|
|
18
|
+
with self.connection() as conn:
|
|
19
|
+
conn.run(f"mkdir -p {self.app_dir}")
|
|
20
|
+
with conn.cd(self.app_dir):
|
|
21
|
+
self.create_hook_manager(conn).pre_deploy()
|
|
22
|
+
self.transfer_files(conn)
|
|
23
|
+
|
|
24
|
+
with self.app_environment() as conn:
|
|
25
|
+
process_manager = self.create_process_manager(conn)
|
|
26
|
+
self.install_project(conn)
|
|
27
|
+
self.release(conn)
|
|
28
|
+
process_manager.install_services()
|
|
29
|
+
process_manager.reload_configuration()
|
|
30
|
+
process_manager.restart_services()
|
|
31
|
+
self.create_web_proxy(conn).setup()
|
|
32
|
+
self.update_version_history(conn)
|
|
33
|
+
self.prune_assets(conn)
|
|
34
|
+
self.create_hook_manager(conn).post_deploy()
|
|
35
|
+
self.stdout.output("[green]Project deployment completed successfully![/green]")
|
|
36
|
+
self.stdout.output(
|
|
37
|
+
f"[blue]Access the deployed project at: https://{self.config.host.domain_name}[/blue]"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def build_app(self) -> None:
|
|
41
|
+
try:
|
|
42
|
+
subprocess.run(self.config.build_command, check=True, shell=True)
|
|
43
|
+
except subprocess.CalledProcessError as e:
|
|
44
|
+
raise cappa.Exit(f"build command failed: {e}", code=1) from e
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def versioned_assets_dir(self) -> str:
|
|
48
|
+
return f"{self.app_dir}/v{self.config.version}"
|
|
49
|
+
|
|
50
|
+
def transfer_files(self, conn: Connection, skip_requirements: bool = False):
|
|
51
|
+
if not self.config.host.envfile.exists():
|
|
52
|
+
raise cappa.Exit(f"{self.config.host.envfile} not found", code=1)
|
|
53
|
+
|
|
54
|
+
if not self.config.requirements.exists():
|
|
55
|
+
raise cappa.Exit(f"{self.config.requirements} not found", code=1)
|
|
56
|
+
conn.put(str(self.config.host.envfile), f"{self.app_dir}/.env")
|
|
57
|
+
conn.run(f"mkdir -p {self.versioned_assets_dir}")
|
|
58
|
+
if not skip_requirements:
|
|
59
|
+
conn.put(
|
|
60
|
+
str(self.config.requirements),
|
|
61
|
+
f"{self.versioned_assets_dir}/requirements.txt",
|
|
62
|
+
)
|
|
63
|
+
distfile_path = self.config.get_distfile_path()
|
|
64
|
+
conn.put(
|
|
65
|
+
str(distfile_path),
|
|
66
|
+
f"{self.versioned_assets_dir}/{distfile_path.name}",
|
|
67
|
+
)
|
|
68
|
+
appenv = f"""
|
|
69
|
+
set -a # Automatically export all variables
|
|
70
|
+
source .env
|
|
71
|
+
set +a # Stop automatic export
|
|
72
|
+
export UV_COMPILE_BYTECODE=1
|
|
73
|
+
export UV_PYTHON=python{self.config.python_version}
|
|
74
|
+
export PATH=".venv/bin:$PATH"
|
|
75
|
+
"""
|
|
76
|
+
conn.run(f"echo '{appenv.strip()}' > .appenv")
|
|
77
|
+
|
|
78
|
+
def install_project(
|
|
79
|
+
self, conn: Connection, version: str | None = None, *, skip_setup: bool = False
|
|
80
|
+
):
|
|
81
|
+
if self.config.skip_project_install:
|
|
82
|
+
return
|
|
83
|
+
version = version or self.config.version
|
|
84
|
+
versioned_assets_dir = f"{self.app_dir}/v{version}"
|
|
85
|
+
if not skip_setup:
|
|
86
|
+
conn.run("uv venv")
|
|
87
|
+
conn.run(f"uv pip install -r {versioned_assets_dir}/requirements.txt")
|
|
88
|
+
conn.run(
|
|
89
|
+
f"uv pip install {versioned_assets_dir}/{self.config.get_distfile_path(version).name}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def release(self, conn: Connection):
|
|
93
|
+
if self.config.release_command:
|
|
94
|
+
conn.run(f"source .env && {self.config.release_command}")
|
|
95
|
+
|
|
96
|
+
def update_version_history(self, conn: Connection):
|
|
97
|
+
result = conn.run("head -n 1 .versions", warn=True, hide=True).stdout.strip()
|
|
98
|
+
if result == self.config.version:
|
|
99
|
+
return
|
|
100
|
+
if result == "":
|
|
101
|
+
conn.run(f"echo '{self.config.version}' > .versions")
|
|
102
|
+
else:
|
|
103
|
+
conn.run(f"sed -i '1i {self.config.version}' .versions")
|
|
104
|
+
|
|
105
|
+
def prune_assets(self, conn: Connection):
|
|
106
|
+
if not self.config.versions_to_keep:
|
|
107
|
+
return
|
|
108
|
+
result = conn.run(
|
|
109
|
+
f"sed -n '{self.config.versions_to_keep + 1},$p' .versions", hide=True
|
|
110
|
+
).stdout.strip()
|
|
111
|
+
result_list = result.split("\n")
|
|
112
|
+
if result == "":
|
|
113
|
+
return
|
|
114
|
+
to_prune = [f"{self.app_dir}/v{v}" for v in result_list]
|
|
115
|
+
conn.run(f"rm -r {' '.join(to_prune)}", warn=True)
|
|
116
|
+
conn.run(f"sed -i '{self.config.versions_to_keep + 1},$d' .versions", warn=True)
|
fujin/commands/docs.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import cappa
|
|
4
|
+
|
|
5
|
+
import fujin.config
|
|
6
|
+
from fujin.commands import BaseCommand
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@cappa.command(help="Configuration documentation")
|
|
10
|
+
class Docs(BaseCommand):
|
|
11
|
+
def __call__(self):
|
|
12
|
+
docs = f"""
|
|
13
|
+
# Fujin Configuration
|
|
14
|
+
{fujin.config.__doc__}
|
|
15
|
+
"""
|
|
16
|
+
self.stdout.output(docs)
|
fujin/commands/down.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import cappa
|
|
7
|
+
from rich.prompt import Confirm
|
|
8
|
+
|
|
9
|
+
from fujin.commands import BaseCommand
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@cappa.command(
|
|
13
|
+
help="Tear down the project by stopping services and cleaning up resources"
|
|
14
|
+
)
|
|
15
|
+
@dataclass
|
|
16
|
+
class Down(BaseCommand):
|
|
17
|
+
full: Annotated[
|
|
18
|
+
bool,
|
|
19
|
+
cappa.Arg(
|
|
20
|
+
short="-f",
|
|
21
|
+
long="--full",
|
|
22
|
+
help="Stop and uninstall proxy as part of teardown",
|
|
23
|
+
),
|
|
24
|
+
] = False
|
|
25
|
+
|
|
26
|
+
def __call__(self):
|
|
27
|
+
try:
|
|
28
|
+
confirm = Confirm.ask(
|
|
29
|
+
f"""[red]You are about to delete all project files, stop all services, and remove all configurations on the host {self.config.host.ip} for the project {self.config.app_name}. 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]""",
|
|
30
|
+
)
|
|
31
|
+
except KeyboardInterrupt as e:
|
|
32
|
+
raise cappa.Exit("Teardown aborted", code=0)
|
|
33
|
+
if not confirm:
|
|
34
|
+
return
|
|
35
|
+
with self.connection() as conn:
|
|
36
|
+
hook_manager = self.create_hook_manager(conn)
|
|
37
|
+
hook_manager.pre_teardown()
|
|
38
|
+
process_manager = self.create_process_manager(conn)
|
|
39
|
+
conn.run(f"rm -rf {self.app_dir}")
|
|
40
|
+
self.create_web_proxy(conn).teardown()
|
|
41
|
+
process_manager.uninstall_services()
|
|
42
|
+
process_manager.reload_configuration()
|
|
43
|
+
if self.full:
|
|
44
|
+
self.create_web_proxy(conn).uninstall()
|
|
45
|
+
hook_manager.post_teardown()
|
|
46
|
+
self.stdout.output(
|
|
47
|
+
"[green]Project teardown completed successfully![/green]"
|
|
48
|
+
)
|
fujin/commands/init.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
|
|
9
|
+
from fujin.commands import BaseCommand
|
|
10
|
+
from fujin.config import tomllib
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@cappa.command(help="Generate a sample configuration file")
|
|
14
|
+
class Init(BaseCommand):
|
|
15
|
+
profile: Annotated[
|
|
16
|
+
str, cappa.Arg(choices=["simple", "falco"], short="-p", long="--profile")
|
|
17
|
+
] = "simple"
|
|
18
|
+
|
|
19
|
+
def __call__(self):
|
|
20
|
+
fujin_toml = Path("fujin.toml")
|
|
21
|
+
if fujin_toml.exists():
|
|
22
|
+
raise cappa.Exit("fujin.toml file already exists", code=1)
|
|
23
|
+
profile_to_func = {"simple": simple_config, "falco": falco_config}
|
|
24
|
+
app_name = Path().resolve().stem.replace("-", "_").replace(" ", "_").lower()
|
|
25
|
+
config = profile_to_func[self.profile](app_name)
|
|
26
|
+
if not Path(".python-version").exists():
|
|
27
|
+
config["python_version"] = "3.12"
|
|
28
|
+
pyproject_toml = Path("pyproject.toml")
|
|
29
|
+
if pyproject_toml.exists():
|
|
30
|
+
pyproject = tomllib.loads(pyproject_toml.read_text())
|
|
31
|
+
config["app"] = pyproject.get("project", {}).get("name", app_name)
|
|
32
|
+
if pyproject.get("project", {}).get("version"):
|
|
33
|
+
# fujin will read the version itself from the pyproject
|
|
34
|
+
config.pop("version")
|
|
35
|
+
fujin_toml.write_text(tomli_w.dumps(config))
|
|
36
|
+
self.stdout.output(
|
|
37
|
+
"[green]Sample configuration file generated successfully![/green]"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def simple_config(app_name) -> dict:
|
|
42
|
+
return {
|
|
43
|
+
"app": app_name,
|
|
44
|
+
"version": "0.1.0",
|
|
45
|
+
"build_command": "uv build && uv pip compile pyproject.toml -o requirements.txt",
|
|
46
|
+
"distfile": f"dist/{app_name}-{{version}}-py3-none-any.whl",
|
|
47
|
+
"webserver": {
|
|
48
|
+
"upstream": "localhost:8000",
|
|
49
|
+
"type": "fujin.proxies.caddy",
|
|
50
|
+
},
|
|
51
|
+
"release_command": f"{app_name} migrate",
|
|
52
|
+
"processes": {
|
|
53
|
+
"web": f".venv/bin/gunicorn {app_name}.wsgi:app --bind 0.0.0.0:8000"
|
|
54
|
+
},
|
|
55
|
+
"aliases": {"shell": "server exec --appenv -i bash"},
|
|
56
|
+
"host": {
|
|
57
|
+
"ip": "127.0.0.1",
|
|
58
|
+
"user": "root",
|
|
59
|
+
"domain_name": f"{app_name}.com",
|
|
60
|
+
"envfile": ".env.prod",
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def falco_config(app_name: str) -> dict:
|
|
66
|
+
config = simple_config(app_name)
|
|
67
|
+
config.update(
|
|
68
|
+
{
|
|
69
|
+
"release_command": f"{config['app']} setup",
|
|
70
|
+
"processes": {
|
|
71
|
+
"web": f".venv/bin/{config['app']} prodserver",
|
|
72
|
+
"worker": f".venv/bin/{config['app']} qcluster",
|
|
73
|
+
},
|
|
74
|
+
"aliases": {
|
|
75
|
+
"console": "app exec -i shell_plus",
|
|
76
|
+
"dbconsole": "app exec -i dbshell",
|
|
77
|
+
"print_settings": "app exec print_settings --format=pprint",
|
|
78
|
+
"shell": "server exec --appenv -i bash",
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
return config
|