fujin-cli 0.3.0__tar.gz → 0.5.0__tar.gz

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.

Files changed (76) hide show
  1. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/PKG-INFO +1 -1
  2. fujin_cli-0.5.0/docs/configuration.rst +32 -0
  3. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/index.rst +3 -0
  4. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/examples/django/bookstore/fujin.toml +14 -13
  5. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/pyproject.toml +1 -1
  6. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/commands/app.py +3 -1
  7. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/commands/config.py +3 -2
  8. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/commands/deploy.py +51 -29
  9. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/commands/down.py +1 -1
  10. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/commands/init.py +53 -16
  11. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/commands/proxy.py +11 -0
  12. fujin_cli-0.5.0/src/fujin/commands/redeploy.py +61 -0
  13. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/commands/server.py +0 -5
  14. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/config.py +30 -19
  15. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/proxies/caddy.py +61 -44
  16. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/uv.lock +1 -1
  17. fujin_cli-0.3.0/docs/configuration.rst +0 -18
  18. fujin_cli-0.3.0/src/fujin/commands/redeploy.py +0 -48
  19. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/.github/workflows/publish.yml +0 -0
  20. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/.gitignore +0 -0
  21. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/.pre-commit-config.yaml +0 -0
  22. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/.readthedocs.yaml +0 -0
  23. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/LICENSE.txt +0 -0
  24. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/README.md +0 -0
  25. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/Vagrantfile +0 -0
  26. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/changelog.rst +0 -0
  27. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/commands/app.rst +0 -0
  28. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/commands/config.rst +0 -0
  29. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/commands/deploy.rst +0 -0
  30. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/commands/docs.rst +0 -0
  31. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/commands/down.rst +0 -0
  32. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/commands/index.rst +0 -0
  33. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/commands/init.rst +0 -0
  34. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/commands/proxy.rst +0 -0
  35. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/commands/prune.rst +0 -0
  36. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/commands/redeploy.rst +0 -0
  37. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/commands/rollback.rst +0 -0
  38. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/commands/secrets.rst +0 -0
  39. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/commands/server.rst +0 -0
  40. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/commands/up.rst +0 -0
  41. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/conf.py +0 -0
  42. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/hooks.rst +0 -0
  43. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/installation.rst +0 -0
  44. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/requirements.txt +0 -0
  45. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/docs/tutorial.rst +0 -0
  46. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/examples/django/bookstore/README.md +0 -0
  47. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/examples/django/bookstore/bookstore/__init__.py +0 -0
  48. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/examples/django/bookstore/bookstore/__main__.py +0 -0
  49. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/examples/django/bookstore/bookstore/asgi.py +0 -0
  50. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/examples/django/bookstore/bookstore/settings.py +0 -0
  51. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/examples/django/bookstore/bookstore/urls.py +0 -0
  52. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/examples/django/bookstore/bookstore/wsgi.py +0 -0
  53. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/examples/django/bookstore/manage.py +0 -0
  54. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/examples/django/bookstore/pyproject.toml +0 -0
  55. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/examples/django/bookstore/requirements.txt +0 -0
  56. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/justfile +0 -0
  57. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/__init__.py +0 -0
  58. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/__main__.py +0 -0
  59. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/commands/__init__.py +0 -0
  60. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/commands/_base.py +0 -0
  61. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/commands/docs.py +0 -0
  62. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/commands/prune.py +0 -0
  63. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/commands/rollback.py +0 -0
  64. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/commands/secrets.py +0 -0
  65. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/commands/up.py +0 -0
  66. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/connection.py +0 -0
  67. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/errors.py +0 -0
  68. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/hooks.py +0 -0
  69. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/process_managers/__init__.py +0 -0
  70. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/process_managers/systemd.py +0 -0
  71. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/proxies/__init__.py +0 -0
  72. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/proxies/dummy.py +0 -0
  73. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/proxies/nginx.py +0 -0
  74. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/templates/simple.service +0 -0
  75. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/templates/web.service +0 -0
  76. {fujin_cli-0.3.0 → fujin_cli-0.5.0}/src/fujin/templates/web.socket +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: fujin-cli
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Add your description here
5
5
  Project-URL: Documentation, https://github.com/falcopackages/fujin#readme
6
6
  Project-URL: Issues, https://github.com/falcopackages/fujin/issues
@@ -0,0 +1,32 @@
1
+ Configuration
2
+ =============
3
+
4
+ .. automodule:: fujin.config
5
+
6
+
7
+ Example
8
+ -------
9
+
10
+ This is a minimal working example.
11
+
12
+ .. tab-set::
13
+
14
+ .. tab-item:: simple python package
15
+
16
+ .. jupyter-execute::
17
+ :hide-code:
18
+
19
+ from fujin.commands.init import simple_config
20
+ from tomli_w import dumps
21
+
22
+ print(dumps(simple_config("bookstore")))
23
+
24
+ .. tab-item:: binary mode
25
+
26
+ .. jupyter-execute::
27
+ :hide-code:
28
+
29
+ from fujin.commands.init import binary_config
30
+ from tomli_w import dumps
31
+
32
+ print(dumps(binary_config("bookstore")))
@@ -11,6 +11,9 @@ fujin documentation
11
11
 
12
12
  This a work in progress, not ready for production use yet.
13
13
 
14
+ .. raw:: html
15
+
16
+ <script src="https://asciinema.org/a/687274.js" id="asciicast-687274" async="true"></script>
14
17
 
15
18
  .. .. include:: ../README.md
16
19
  :parser: myst_parser.sphinx_
@@ -3,8 +3,9 @@ requirements = "requirements.txt"
3
3
  python_version = "3.13"
4
4
  versions_to_keep = 2
5
5
  build_command = "uv build && uv pip compile pyproject.toml -o requirements.txt"
6
- release_command = "bookstore migrate && bookstore collectstatic --no-input && sudo rsync --mkpath -a --delete staticfiles/ /var/www/bookstore/static/"
6
+ release_command = "bookstore migrate && bookstore collectstatic --no-input && sudo mkdir -p /var/www/bookstore/static/ && sudo rsync -a --delete staticfiles/ /var/www/bookstore/static/"
7
7
  distfile = "./../../../dist/bookstore-{version}-py3-none-any.whl"
8
+ installation_mode = "python-package"
8
9
 
9
10
  [webserver]
10
11
  upstream = "unix//run/bookstore.sock"
@@ -20,18 +21,18 @@ shell = "server exec --appenv -i bash"
20
21
  web = ".venv/bin/gunicorn bookstore.wsgi:application --access-logfile - --error-logfile -"
21
22
  worker = ".venv/bin/bookstore db_worker"
22
23
 
23
- #[host]
24
- #ip = "127.0.0.1"
25
- #domain_name = "bookstore.localhost.dev"
26
- #envfile = ".env.prod"
27
- #user = "vagrant"
28
- #key_filename = "/home/tobi/Builds/falcopackages/fujin/.vagrant/machines/default/virtualbox/private_key"
29
- #ssh_port = 2222
30
-
31
24
  [host]
32
- ip = "aws-ec2"
33
- domain_name = "test.oluwatobi.dev"
25
+ ip = "127.0.0.1"
26
+ domain_name = "bookstore.localhost.dev"
34
27
  envfile = ".env.prod"
35
- user = "ubuntu"
36
- key_filename = "../../../aws.pem"
28
+ user = "vagrant"
29
+ key_filename = "/home/tobi/Builds/falcopackages/fujin/.vagrant/machines/default/virtualbox/private_key"
30
+ ssh_port = 2222
31
+
32
+ #[host]
33
+ #ip = "aws-ec2"
34
+ #domain_name = "test.oluwatobi.dev"
35
+ #envfile = ".env.prod"
36
+ #user = "ubuntu"
37
+ #key_filename = "../../../aws.pem"
37
38
 
@@ -7,7 +7,7 @@ requires = [
7
7
 
8
8
  [project]
9
9
  name = "fujin-cli"
10
- version = "0.3.0"
10
+ version = "0.5.0"
11
11
  description = "Add your description here"
12
12
  readme = "README.md"
13
13
  keywords = [
@@ -5,6 +5,7 @@ from typing import Annotated
5
5
  import cappa
6
6
 
7
7
  from fujin.commands import BaseCommand
8
+ from fujin.config import InstallationMode
8
9
 
9
10
 
10
11
  @cappa.command(help="Run application-related tasks")
@@ -25,11 +26,12 @@ class App(BaseCommand):
25
26
  "app_bin": self.config.app_bin,
26
27
  "local_version": self.config.version,
27
28
  "remote_version": remote_version,
28
- "python_version": self.config.python_version,
29
29
  "rollback_targets": ", ".join(rollback_targets.split("\n"))
30
30
  if rollback_targets
31
31
  else "N/A",
32
32
  }
33
+ if self.config.installation_mode == InstallationMode.PY_PACKAGE:
34
+ infos["python_version"] = self.config.python_version
33
35
  pm = self.create_process_manager(conn)
34
36
  services: dict[str, bool] = pm.is_active()
35
37
 
@@ -16,13 +16,14 @@ class ConfigCMD(BaseCommand):
16
16
  "app": self.config.app_name,
17
17
  "app_bin": self.config.app_bin,
18
18
  "version": self.config.version,
19
- "python_version": self.config.python_version,
20
19
  "build_command": self.config.build_command,
21
20
  "release_command": self.config.release_command,
21
+ "installation_mode": self.config.installation_mode,
22
22
  "distfile": self.config.distfile,
23
- "requirements": self.config.requirements,
24
23
  "webserver": f"{{ upstream = '{self.config.webserver.upstream}', type = '{self.config.webserver.type}' }}",
25
24
  }
25
+ if self.config.python_version:
26
+ general_config["python_version"] = self.config.python_version
26
27
  general_config_text = "\n".join(
27
28
  f"[bold green]{key}:[/bold green] {value}"
28
29
  for key, value in general_config.items()
@@ -1,10 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import subprocess
4
+ from pathlib import Path
4
5
 
5
6
  import cappa
6
7
 
7
8
  from fujin.commands import BaseCommand
9
+ from fujin.config import InstallationMode
8
10
  from fujin.connection import Connection
9
11
 
10
12
 
@@ -16,21 +18,20 @@ class Deploy(BaseCommand):
16
18
  self.build_app()
17
19
 
18
20
  with self.connection() as conn:
21
+ process_manager = self.create_process_manager(conn)
19
22
  conn.run(f"mkdir -p {self.app_dir}")
20
23
  with conn.cd(self.app_dir):
21
24
  self.create_hook_manager(conn).pre_deploy()
22
25
  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)
26
+ self.install_project(conn)
27
+ with self.app_environment() as app_conn:
28
+ self.release(app_conn)
29
+ process_manager.install_services()
30
+ process_manager.reload_configuration()
31
+ process_manager.restart_services()
32
+ self.create_web_proxy(app_conn).setup()
33
+ self.update_version_history(app_conn)
34
+ self.prune_assets(app_conn)
34
35
  self.create_hook_manager(conn).post_deploy()
35
36
  self.stdout.output("[green]Project deployment completed successfully![/green]")
36
37
  self.stdout.output(
@@ -47,24 +48,37 @@ class Deploy(BaseCommand):
47
48
  def versioned_assets_dir(self) -> str:
48
49
  return f"{self.app_dir}/v{self.config.version}"
49
50
 
50
- def transfer_files(self, conn: Connection, skip_requirements: bool = False):
51
+ def transfer_files(self, conn: Connection):
51
52
  if not self.config.host.envfile.exists():
52
53
  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
54
  conn.put(str(self.config.host.envfile), f"{self.app_dir}/.env")
57
55
  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
56
  distfile_path = self.config.get_distfile_path()
64
57
  conn.put(
65
58
  str(distfile_path),
66
59
  f"{self.versioned_assets_dir}/{distfile_path.name}",
67
60
  )
61
+
62
+ def install_project(
63
+ self, conn: Connection, version: str | None = None, *, skip_setup: bool = False
64
+ ):
65
+ version = version or self.config.version
66
+ if self.config.installation_mode == InstallationMode.PY_PACKAGE:
67
+ self._install_python_package(conn, version, skip_setup)
68
+ else:
69
+ self._install_binary(conn, version)
70
+
71
+ def _install_python_package(
72
+ self, conn: Connection, version: str, skip_setup: bool = False
73
+ ):
74
+ if not skip_setup and self.config.requirements:
75
+ requirements = Path(self.config.requirements)
76
+ if not requirements.exists():
77
+ raise cappa.Exit(f"{self.config.requirements} not found", code=1)
78
+ conn.put(
79
+ Path(self.config.requirements).resolve(),
80
+ f"{self.versioned_assets_dir}/requirements.txt",
81
+ )
68
82
  appenv = f"""
69
83
  set -a # Automatically export all variables
70
84
  source .env
@@ -73,22 +87,30 @@ export UV_COMPILE_BYTECODE=1
73
87
  export UV_PYTHON=python{self.config.python_version}
74
88
  export PATH=".venv/bin:$PATH"
75
89
  """
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
90
+ conn.run(f"echo '{appenv.strip()}' > {self.app_dir}/.appenv")
84
91
  versioned_assets_dir = f"{self.app_dir}/v{version}"
85
92
  if not skip_setup:
86
93
  conn.run("uv venv")
87
- conn.run(f"uv pip install -r {versioned_assets_dir}/requirements.txt")
94
+ if self.config.requirements:
95
+ conn.run(f"uv pip install -r {versioned_assets_dir}/requirements.txt")
88
96
  conn.run(
89
97
  f"uv pip install {versioned_assets_dir}/{self.config.get_distfile_path(version).name}"
90
98
  )
91
99
 
100
+ def _install_binary(self, conn: Connection, version: str):
101
+ appenv = f"""
102
+ set -a # Automatically export all variables
103
+ source .env
104
+ set +a # Stop automatic export
105
+ export PATH="{self.app_dir}:$PATH"
106
+ """
107
+ conn.run(f"echo '{appenv.strip()}' > {self.app_dir}/.appenv")
108
+ full_path_app_bin = f"{self.app_dir}/{self.config.app_bin}"
109
+ conn.run(f"rm {full_path_app_bin}", warn=True)
110
+ conn.run(
111
+ f"ln -s {self.versioned_assets_dir}/{self.config.get_distfile_path(version).name} {full_path_app_bin}"
112
+ )
113
+
92
114
  def release(self, conn: Connection):
93
115
  if self.config.release_command:
94
116
  conn.run(f"source .env && {self.config.release_command}")
@@ -28,7 +28,7 @@ class Down(BaseCommand):
28
28
  confirm = Confirm.ask(
29
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
30
  )
31
- except KeyboardInterrupt as e:
31
+ except KeyboardInterrupt:
32
32
  raise cappa.Exit("Teardown aborted", code=0)
33
33
  if not confirm:
34
34
  return
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from dataclasses import dataclass
3
4
  from pathlib import Path
4
5
  from typing import Annotated
5
6
 
@@ -7,31 +8,28 @@ import cappa
7
8
  import tomli_w
8
9
 
9
10
  from fujin.commands import BaseCommand
10
- from fujin.config import tomllib
11
+ from fujin.config import tomllib, InstallationMode
11
12
 
12
13
 
13
14
  @cappa.command(help="Generate a sample configuration file")
15
+ @dataclass
14
16
  class Init(BaseCommand):
15
17
  profile: Annotated[
16
- str, cappa.Arg(choices=["simple", "falco"], short="-p", long="--profile")
18
+ str,
19
+ cappa.Arg(choices=["simple", "falco", "binary"], short="-p", long="--profile"),
17
20
  ] = "simple"
18
21
 
19
22
  def __call__(self):
20
23
  fujin_toml = Path("fujin.toml")
21
24
  if fujin_toml.exists():
22
25
  raise cappa.Exit("fujin.toml file already exists", code=1)
23
- profile_to_func = {"simple": simple_config, "falco": falco_config}
26
+ profile_to_func = {
27
+ "simple": simple_config,
28
+ "falco": falco_config,
29
+ "binary": binary_config,
30
+ }
24
31
  app_name = Path().resolve().stem.replace("-", "_").replace(" ", "_").lower()
25
32
  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
33
  fujin_toml.write_text(tomli_w.dumps(config))
36
34
  self.stdout.output(
37
35
  "[green]Sample configuration file generated successfully![/green]"
@@ -39,18 +37,20 @@ class Init(BaseCommand):
39
37
 
40
38
 
41
39
  def simple_config(app_name) -> dict:
42
- return {
40
+ config = {
43
41
  "app": app_name,
44
- "version": "0.1.0",
42
+ "version": "0.0.1",
45
43
  "build_command": "uv build && uv pip compile pyproject.toml -o requirements.txt",
46
44
  "distfile": f"dist/{app_name}-{{version}}-py3-none-any.whl",
45
+ "requirements": "requirements.txt",
47
46
  "webserver": {
48
- "upstream": "localhost:8000",
47
+ "upstream": f"unix//run/{app_name}.sock",
49
48
  "type": "fujin.proxies.caddy",
50
49
  },
51
50
  "release_command": f"{app_name} migrate",
51
+ "installation_mode": InstallationMode.PY_PACKAGE,
52
52
  "processes": {
53
- "web": f".venv/bin/gunicorn {app_name}.wsgi:app --bind 0.0.0.0:8000"
53
+ "web": f".venv/bin/gunicorn {app_name}.wsgi:application --bind unix//run/{app_name}.sock"
54
54
  },
55
55
  "aliases": {"shell": "server exec --appenv -i bash"},
56
56
  "host": {
@@ -60,6 +60,16 @@ def simple_config(app_name) -> dict:
60
60
  "envfile": ".env.prod",
61
61
  },
62
62
  }
63
+ if not Path(".python-version").exists():
64
+ config["python_version"] = "3.12"
65
+ pyproject_toml = Path("pyproject.toml")
66
+ if pyproject_toml.exists():
67
+ pyproject = tomllib.loads(pyproject_toml.read_text())
68
+ config["app"] = pyproject.get("project", {}).get("name", app_name)
69
+ if pyproject.get("project", {}).get("version"):
70
+ # fujin will read the version itself from the pyproject
71
+ config.pop("version")
72
+ return config
63
73
 
64
74
 
65
75
  def falco_config(app_name: str) -> dict:
@@ -71,6 +81,10 @@ def falco_config(app_name: str) -> dict:
71
81
  "web": f".venv/bin/{config['app']} prodserver",
72
82
  "worker": f".venv/bin/{config['app']} qcluster",
73
83
  },
84
+ "webserver": {
85
+ "upstream": "localhost:8000",
86
+ "type": "fujin.proxies.caddy",
87
+ },
74
88
  "aliases": {
75
89
  "console": "app exec -i shell_plus",
76
90
  "dbconsole": "app exec -i dbshell",
@@ -80,3 +94,26 @@ def falco_config(app_name: str) -> dict:
80
94
  }
81
95
  )
82
96
  return config
97
+
98
+
99
+ def binary_config(app_name: str) -> dict:
100
+ return {
101
+ "app": app_name,
102
+ "version": "0.0.1",
103
+ "build_command": "just build-bin",
104
+ "distfile": f"dist/bin/{app_name}-{{version}}",
105
+ "webserver": {
106
+ "upstream": "localhost:8000",
107
+ "type": "fujin.proxies.caddy",
108
+ },
109
+ "release_command": f"{app_name} migrate",
110
+ "installation_mode": InstallationMode.BINARY,
111
+ "processes": {"web": f"{app_name} prodserver"},
112
+ "aliases": {"shell": "server exec --appenv -i bash"},
113
+ "host": {
114
+ "ip": "127.0.0.1",
115
+ "user": "root",
116
+ "domain_name": f"{app_name}.com",
117
+ "envfile": ".env.prod",
118
+ },
119
+ }
@@ -2,6 +2,7 @@ from dataclasses import dataclass
2
2
  from typing import Annotated
3
3
 
4
4
  import cappa
5
+ from rich.prompt import Confirm
5
6
 
6
7
  from fujin.commands import BaseCommand
7
8
 
@@ -13,11 +14,21 @@ class Proxy(BaseCommand):
13
14
  def install(self):
14
15
  with self.connection() as conn:
15
16
  self.create_web_proxy(conn).install()
17
+ self.stdout.output("[green]Proxy installed successfully![/green]")
16
18
 
17
19
  @cappa.command(help="Uninstall the proxy from the remote host")
18
20
  def uninstall(self):
21
+ try:
22
+ confirm = Confirm.ask(
23
+ f"[red]Uninstalling the proxy will remove all current configurations. Are you sure you want to proceed?"
24
+ )
25
+ except KeyboardInterrupt:
26
+ raise cappa.Exit("Teardown aborted", code=0)
27
+ if not confirm:
28
+ return
19
29
  with self.connection() as conn:
20
30
  self.create_web_proxy(conn).uninstall()
31
+ self.stdout.output("[green]Proxy uninstalled successfully![/green]")
21
32
 
22
33
  @cappa.command(help="Start the proxy on the remote host")
23
34
  def start(self):
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+
6
+ import cappa
7
+
8
+ from fujin.commands import BaseCommand
9
+ from .deploy import Deploy
10
+ from fujin.config import InstallationMode
11
+ from fujin.connection import Connection
12
+
13
+
14
+ @cappa.command(help="Redeploy the application to apply code and environment changes")
15
+ class Redeploy(BaseCommand):
16
+ def __call__(self):
17
+ deploy = Deploy()
18
+ deploy.build_app()
19
+
20
+ with self.app_environment() as conn:
21
+ hook_manager = self.create_hook_manager(conn)
22
+ hook_manager.pre_deploy()
23
+ deploy.transfer_files(conn)
24
+ requirements_copied = self._copy_requirements_if_needed(conn)
25
+ deploy.install_project(conn, skip_setup=requirements_copied)
26
+ deploy.release(conn)
27
+ self.create_process_manager(conn).restart_services()
28
+ deploy.update_version_history(conn)
29
+ hook_manager.post_deploy()
30
+ self.stdout.output("[green]Redeployment completed successfully![/green]")
31
+
32
+ def _copy_requirements_if_needed(self, conn: Connection) -> bool:
33
+ if (
34
+ not self.config.requirements
35
+ or self.config.installation_mode == InstallationMode.BINARY
36
+ ):
37
+ return False
38
+ local_requirements = hashlib.md5(
39
+ Path(self.config.requirements).read_bytes()
40
+ ).hexdigest()
41
+ current_host_version = conn.run(
42
+ "head -n 1 .versions", warn=True, hide=True
43
+ ).stdout.strip()
44
+ try:
45
+ host_requirements = (
46
+ conn.run(
47
+ f"md5sum v{current_host_version}/requirements.txt",
48
+ warn=True,
49
+ hide=True,
50
+ )
51
+ .stdout.strip()
52
+ .split()[0]
53
+ )
54
+ skip_requirements = host_requirements == local_requirements
55
+ except IndexError:
56
+ return False
57
+ if skip_requirements and current_host_version != self.config.version:
58
+ conn.run(
59
+ f"cp v{current_host_version}/requirements.txt {self.app_dir}/v{self.config.version}/requirements.txt "
60
+ )
61
+ return True
@@ -38,11 +38,6 @@ class Server(BaseCommand):
38
38
  "[green]Server bootstrap completed successfully![/green]"
39
39
  )
40
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
41
  @cappa.command(
47
42
  help="Execute an arbitrary command on the server, optionally in interactive mode"
48
43
  )
@@ -5,18 +5,14 @@ app
5
5
  ---
6
6
  The name of your project or application. Must be a valid Python package name.
7
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
8
  version
14
9
  --------
15
10
  The version of your project to build and deploy. If not specified, automatically parsed from ``pyproject.toml`` under ``project.version``.
16
11
 
17
12
  python_version
18
13
  --------------
19
- The Python version for your virtualenv. If not specified, automatically parsed from ``.python-version`` file.
14
+ The Python version for your virtualenv. If not specified, automatically parsed from ``.python-version`` file. This is only
15
+ required if the installation mode is set to ``python-package``
20
16
 
21
17
  versions_to_keep
22
18
  ----------------
@@ -32,14 +28,20 @@ distfile
32
28
  Path to your project's distribution file. This should be the main artifact containing everything needed to run your project on the server.
33
29
  Supports version placeholder, e.g., ``dist/app_name-{version}-py3-none-any.whl``
34
30
 
31
+ installation_mode
32
+ -----------------
33
+
34
+ Indicates whether the ``distfile`` is a Python package or a self-contained executable. The possible values are ``python-package`` and ``binary``.
35
+ The ``binary`` option disables specific Python-related features, such as virtual environment creation and requirements installation. ``fujin`` will assume the provided
36
+ ``distfile`` already contains all the necessary dependencies to run your program.
37
+
35
38
  release_command
36
39
  ---------------
37
40
  Optional command to run at the end of deployment (e.g., database migrations).
38
41
 
39
42
  requirements
40
43
  ------------
41
- Path to your requirements file.
42
- Default: ``requirements.txt``
44
+ Optional path to your requirements file. This will only be used when the installation mode is set to ``python-package``
43
45
 
44
46
  Webserver
45
47
  ---------
@@ -63,7 +65,8 @@ statics
63
65
  ~~~~~~~
64
66
 
65
67
  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.
68
+ file serving depend on the selected reverse proxy. The directories you map should be accessible by the web server, meaning
69
+ with read permissions for the ``www-data`` group; a reliable choice is ``/var/www``.
67
70
 
68
71
  Example:
69
72
 
@@ -72,7 +75,7 @@ Example:
72
75
  [webserver]
73
76
  upstream = "unix//run/project.sock"
74
77
  type = "fujin.proxies.caddy"
75
- statics = { "/static/*" = "/var/www/example.com/static/" }
78
+ statics = { "/static/*" = "/var/www/myproject/static/" }
76
79
 
77
80
  processes
78
81
  ---------
@@ -159,6 +162,7 @@ from __future__ import annotations
159
162
 
160
163
  import os
161
164
  import sys
165
+ from functools import cached_property
162
166
  from pathlib import Path
163
167
 
164
168
  import msgspec
@@ -170,31 +174,36 @@ if sys.version_info >= (3, 11):
170
174
  else:
171
175
  import tomli as tomllib
172
176
 
173
- from .hooks import HooksDict
177
+ from .hooks import HooksDict, StrEnum
178
+
179
+
180
+ class InstallationMode(StrEnum):
181
+ PY_PACKAGE = "python-package"
182
+ BINARY = "binary"
174
183
 
175
184
 
176
185
  class Config(msgspec.Struct, kw_only=True):
177
186
  app_name: str = msgspec.field(name="app")
178
- app_bin: str = ".venv/bin/{app}"
179
187
  version: str = msgspec.field(default_factory=lambda: read_version_from_pyproject())
180
188
  versions_to_keep: int | None = 5
181
- python_version: str = msgspec.field(default_factory=lambda: find_python_version())
189
+ python_version: str | None = None
182
190
  build_command: str
183
191
  release_command: str | None = None
184
- skip_project_install: bool = False
192
+ installation_mode: InstallationMode
185
193
  distfile: str
186
194
  aliases: dict[str, str] = msgspec.field(default_factory=dict)
187
195
  host: HostConfig
188
196
  processes: dict[str, str] = msgspec.field(default_factory=dict)
189
197
  process_manager: str = "fujin.process_managers.systemd"
190
198
  webserver: Webserver
191
- _requirements: str = msgspec.field(name="requirements", default="requirements.txt")
199
+ requirements: str | None = None
192
200
  hooks: HooksDict = msgspec.field(default_factory=dict)
193
201
  local_config_dir: Path = Path(".fujin")
194
202
 
195
203
  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)
204
+ if self.installation_mode == InstallationMode.PY_PACKAGE:
205
+ if not self.python_version:
206
+ self.python_version = find_python_version()
198
207
 
199
208
  if "web" not in self.processes and self.webserver.type != "fujin.proxies.dummy":
200
209
  raise ValueError(
@@ -202,8 +211,10 @@ class Config(msgspec.Struct, kw_only=True):
202
211
  )
203
212
 
204
213
  @property
205
- def requirements(self) -> Path:
206
- return Path(self._requirements)
214
+ def app_bin(self) -> str:
215
+ if self.installation_mode == InstallationMode.PY_PACKAGE:
216
+ return f".venv/bin/{self.app_name}"
217
+ return self.app_name
207
218
 
208
219
  def get_distfile_path(self, version: str | None = None) -> Path:
209
220
  version = version or self.version
@@ -49,6 +49,9 @@ class WebProxy(msgspec.Struct):
49
49
  return self.conn.run(*args, **kwargs, pty=True)
50
50
 
51
51
  def install(self):
52
+ result = self.conn.run(f"command -v caddy", warn=True, hide=True)
53
+ if result.ok:
54
+ return
52
55
  version = get_latest_gh_tag()
53
56
  download_url = GH_DOWNL0AD_URL.format(version=version)
54
57
  filename = GH_TAR_FILENAME.format(version=version)
@@ -71,6 +74,9 @@ class WebProxy(msgspec.Struct):
71
74
  )
72
75
  self.run_pty("sudo systemctl daemon-reload")
73
76
  self.run_pty("sudo systemctl enable --now caddy-api")
77
+ self.conn.run(
78
+ """curl --silent http://localhost:2019/config/ -d '{"apps":{"http": {"servers": {"srv0":{"listen":[":443"]}}}}}' -H 'Content-Type: application/json'"""
79
+ )
74
80
 
75
81
  def uninstall(self):
76
82
  self.stop()
@@ -80,51 +86,31 @@ class WebProxy(msgspec.Struct):
80
86
  self.run_pty("sudo userdel caddy")
81
87
 
82
88
  def setup(self):
83
- config = (
89
+ current_config = json.loads(
90
+ self.conn.run(
91
+ "curl http://localhost:2019/config/apps/http/servers/srv0", hide=True
92
+ ).stdout.strip()
93
+ )
94
+ existing_routes: list[dict] = current_config.get("routes", [])
95
+ new_routes = [r for r in existing_routes if r.get("group") != self.app_name]
96
+ routes = (
84
97
  json.loads(self.config_file.read_text())
85
98
  if self.config_file.exists()
86
- else self._get_config()
99
+ else self._get_routes()
87
100
  )
88
- self.conn.run(f"echo '{json.dumps(config)}' > caddy.json")
89
- self.conn.run(
90
- f"curl localhost:2019/config/apps/http/servers/{self.app_name} -H 'Content-Type: application/json' -d @caddy.json"
91
- )
92
- # TODO: stop when received an {"error":"loading config: loading new config: http app module: start: listening on :443: listen tcp :443: bind: permission denied"}, not a 200 ok
93
-
94
- def teardown(self):
95
- self.conn.run(f"echo '{json.dumps({})}' > caddy.json")
101
+ new_routes.append(routes)
102
+ current_config["routes"] = new_routes
96
103
  self.conn.run(
97
- f"curl localhost:2019/config/apps/http/servers/{self.app_name} -H 'Content-Type: application/json' -d @caddy.json"
104
+ f"curl localhost:2019/config/apps/http/servers/srv0 -H 'Content-Type: application/json' -d '{json.dumps(current_config)}'"
98
105
  )
99
106
 
100
- def start(self) -> None:
101
- self.run_pty("sudo systemctl start caddy-api")
102
-
103
- def stop(self) -> None:
104
- self.run_pty("sudo systemctl stop caddy-api")
105
-
106
- def status(self) -> None:
107
- self.run_pty("sudo systemctl status caddy-api", warn=True)
108
-
109
- def restart(self) -> None:
110
- self.run_pty("sudo systemctl restart caddy-api")
111
-
112
- def logs(self) -> None:
113
- self.run_pty(f"sudo journalctl -u caddy-api -f", warn=True)
114
-
115
- def export_config(self) -> None:
116
- self.config_file.write_text(json.dumps(self._get_config()))
117
-
118
- def _get_config(self) -> dict:
107
+ def _get_routes(self) -> dict:
119
108
  handle = []
120
- config = {
121
- "listen": [":443"],
122
- "routes": [
123
- {
124
- "match": [{"host": [self.domain_name]}],
125
- "handle": handle,
126
- }
127
- ],
109
+ routes = {
110
+ "group": self.app_name,
111
+ "match": [{"host": [self.domain_name]}],
112
+ "terminal": True,
113
+ "handle": handle,
128
114
  }
129
115
  reverse_proxy = {
130
116
  "handler": "reverse_proxy",
@@ -132,14 +118,14 @@ class WebProxy(msgspec.Struct):
132
118
  }
133
119
  if not self.statics:
134
120
  handle.append(reverse_proxy)
135
- return config
136
- routes = []
137
- handle.append({"handler": "subroute", "routes": routes})
121
+ return routes
122
+ sub_routes = []
123
+ handle.append({"handler": "subroute", "routes": sub_routes})
138
124
  for path, directory in self.statics.items():
139
125
  strip_path_prefix = path.replace("/*", "")
140
126
  if strip_path_prefix.endswith("/"):
141
127
  strip_path_prefix = strip_path_prefix[:-1]
142
- routes.append(
128
+ sub_routes.append(
143
129
  {
144
130
  "handle": [
145
131
  {
@@ -167,8 +153,39 @@ class WebProxy(msgspec.Struct):
167
153
  "match": [{"path": [path]}],
168
154
  }
169
155
  )
170
- routes.append({"handle": [reverse_proxy]})
171
- return config
156
+ sub_routes.append({"handle": [reverse_proxy]})
157
+ return routes
158
+
159
+ def teardown(self):
160
+ current_config = json.loads(
161
+ self.conn.run(
162
+ "curl http://localhost:2019/config/apps/http/servers/srv0"
163
+ ).stdout.strip()
164
+ )
165
+ existing_routes: list[dict] = current_config.get("routes", [])
166
+ new_routes = [r for r in existing_routes if r.get("group") != self.app_name]
167
+ current_config["routes"] = new_routes
168
+ self.conn.run(
169
+ f"curl localhost:2019/config/apps/http/servers/srv0 -H 'Content-Type: application/json' -d '{json.dumps(current_config)}'"
170
+ )
171
+
172
+ def start(self) -> None:
173
+ self.run_pty("sudo systemctl start caddy-api")
174
+
175
+ def stop(self) -> None:
176
+ self.run_pty("sudo systemctl stop caddy-api")
177
+
178
+ def status(self) -> None:
179
+ self.run_pty("sudo systemctl status caddy-api", warn=True)
180
+
181
+ def restart(self) -> None:
182
+ self.run_pty("sudo systemctl restart caddy-api")
183
+
184
+ def logs(self) -> None:
185
+ self.run_pty(f"sudo journalctl -u caddy-api -f", warn=True)
186
+
187
+ def export_config(self) -> None:
188
+ self.config_file.write_text(json.dumps(self._get_routes()))
172
189
 
173
190
 
174
191
  def get_latest_gh_tag() -> str:
@@ -599,7 +599,7 @@ wheels = [
599
599
 
600
600
  [[package]]
601
601
  name = "fujin-cli"
602
- version = "0.2.0"
602
+ version = "0.4.0"
603
603
  source = { editable = "." }
604
604
  dependencies = [
605
605
  { name = "cappa" },
@@ -1,18 +0,0 @@
1
- Configuration
2
- =============
3
-
4
- .. automodule:: fujin.config
5
-
6
-
7
- Example
8
- -------
9
-
10
- This is a minimal working example.
11
-
12
- .. jupyter-execute::
13
- :hide-code:
14
-
15
- from fujin.commands.init import simple_config
16
- from tomli_w import dumps
17
-
18
- print(dumps(simple_config("bookstore")))
@@ -1,48 +0,0 @@
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]")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes