fujin-cli 0.6.0__py3-none-any.whl → 0.7.1__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/__main__.py +6 -0
- fujin/commands/__init__.py +0 -1
- fujin/commands/_base.py +2 -1
- fujin/commands/deploy.py +23 -12
- fujin/commands/init.py +2 -1
- fujin/commands/printenv.py +18 -0
- fujin/commands/redeploy.py +6 -2
- fujin/commands/rollback.py +2 -1
- fujin/commands/up.py +1 -1
- fujin/config.py +33 -1
- fujin/connection.py +3 -5
- fujin/hooks.py +2 -1
- fujin/proxies/nginx.py +4 -5
- fujin/secrets/__init__.py +34 -0
- fujin/secrets/bitwarden.py +73 -0
- fujin/secrets/onepassword.py +27 -0
- {fujin_cli-0.6.0.dist-info → fujin_cli-0.7.1.dist-info}/METADATA +26 -14
- fujin_cli-0.7.1.dist-info/RECORD +38 -0
- fujin/commands/secrets.py +0 -11
- fujin_cli-0.6.0.dist-info/RECORD +0 -35
- {fujin_cli-0.6.0.dist-info → fujin_cli-0.7.1.dist-info}/WHEEL +0 -0
- {fujin_cli-0.6.0.dist-info → fujin_cli-0.7.1.dist-info}/entry_points.txt +0 -0
- {fujin_cli-0.6.0.dist-info → fujin_cli-0.7.1.dist-info}/licenses/LICENSE.txt +0 -0
fujin/__main__.py
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
from gevent import monkey
|
|
2
|
+
|
|
3
|
+
monkey.patch_all()
|
|
4
|
+
|
|
1
5
|
import shlex
|
|
2
6
|
import sys
|
|
3
7
|
from pathlib import Path
|
|
@@ -16,6 +20,7 @@ from fujin.commands.redeploy import Redeploy
|
|
|
16
20
|
from fujin.commands.rollback import Rollback
|
|
17
21
|
from fujin.commands.server import Server
|
|
18
22
|
from fujin.commands.up import Up
|
|
23
|
+
from fujin.commands.printenv import Printenv
|
|
19
24
|
|
|
20
25
|
if sys.version_info >= (3, 11):
|
|
21
26
|
import tomllib
|
|
@@ -38,6 +43,7 @@ class Fujin:
|
|
|
38
43
|
| Down
|
|
39
44
|
| Rollback
|
|
40
45
|
| Prune
|
|
46
|
+
| Printenv
|
|
41
47
|
]
|
|
42
48
|
|
|
43
49
|
|
fujin/commands/__init__.py
CHANGED
fujin/commands/_base.py
CHANGED
|
@@ -6,7 +6,8 @@ from functools import cached_property
|
|
|
6
6
|
import cappa
|
|
7
7
|
|
|
8
8
|
from fujin.config import Config
|
|
9
|
-
from fujin.connection import
|
|
9
|
+
from fujin.connection import Connection
|
|
10
|
+
from fujin.connection import host_connection
|
|
10
11
|
from fujin.errors import ImproperlyConfiguredError
|
|
11
12
|
from fujin.hooks import HookManager
|
|
12
13
|
from fujin.process_managers import ProcessManager
|
fujin/commands/deploy.py
CHANGED
|
@@ -8,6 +8,7 @@ import cappa
|
|
|
8
8
|
from fujin.commands import BaseCommand
|
|
9
9
|
from fujin.config import InstallationMode
|
|
10
10
|
from fujin.connection import Connection
|
|
11
|
+
from fujin.secrets import resolve_secrets
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
@cappa.command(
|
|
@@ -15,14 +16,16 @@ from fujin.connection import Connection
|
|
|
15
16
|
)
|
|
16
17
|
class Deploy(BaseCommand):
|
|
17
18
|
def __call__(self):
|
|
19
|
+
parsed_env = self.parse_envfile()
|
|
18
20
|
self.build_app()
|
|
19
21
|
|
|
20
22
|
with self.connection() as conn:
|
|
21
23
|
process_manager = self.create_process_manager(conn)
|
|
22
24
|
conn.run(f"mkdir -p {self.app_dir}")
|
|
25
|
+
conn.run(f"mkdir -p {self.versioned_assets_dir}")
|
|
23
26
|
with conn.cd(self.app_dir):
|
|
24
27
|
self.create_hook_manager(conn).pre_deploy()
|
|
25
|
-
self.transfer_files(conn)
|
|
28
|
+
self.transfer_files(conn, env=parsed_env)
|
|
26
29
|
self.install_project(conn)
|
|
27
30
|
with self.app_environment() as app_conn:
|
|
28
31
|
self.release(app_conn)
|
|
@@ -48,16 +51,31 @@ class Deploy(BaseCommand):
|
|
|
48
51
|
def versioned_assets_dir(self) -> str:
|
|
49
52
|
return f"{self.app_dir}/v{self.config.version}"
|
|
50
53
|
|
|
51
|
-
def
|
|
54
|
+
def parse_envfile(self) -> str:
|
|
52
55
|
if not self.config.host.envfile.exists():
|
|
53
56
|
raise cappa.Exit(f"{self.config.host.envfile} not found", code=1)
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
if self.config.secret_config:
|
|
58
|
+
self.stdout.output("[blue]Reading secrets....[/blue]")
|
|
59
|
+
return resolve_secrets(self.config.host.envfile, self.config.secret_config)
|
|
60
|
+
return self.config.host.envfile.read_text()
|
|
61
|
+
|
|
62
|
+
def transfer_files(
|
|
63
|
+
self, conn: Connection, env: str, skip_requirements: bool = False
|
|
64
|
+
):
|
|
65
|
+
conn.run(f"echo '{env}' > {self.app_dir}/.env")
|
|
56
66
|
distfile_path = self.config.get_distfile_path()
|
|
57
67
|
conn.put(
|
|
58
68
|
str(distfile_path),
|
|
59
69
|
f"{self.versioned_assets_dir}/{distfile_path.name}",
|
|
60
70
|
)
|
|
71
|
+
if not skip_requirements and self.config.requirements:
|
|
72
|
+
requirements = Path(self.config.requirements)
|
|
73
|
+
if not requirements.exists():
|
|
74
|
+
raise cappa.Exit(f"{self.config.requirements} not found", code=1)
|
|
75
|
+
conn.put(
|
|
76
|
+
Path(self.config.requirements).resolve(),
|
|
77
|
+
f"{self.versioned_assets_dir}/requirements.txt",
|
|
78
|
+
)
|
|
61
79
|
|
|
62
80
|
def install_project(
|
|
63
81
|
self, conn: Connection, version: str | None = None, *, skip_setup: bool = False
|
|
@@ -71,14 +89,6 @@ class Deploy(BaseCommand):
|
|
|
71
89
|
def _install_python_package(
|
|
72
90
|
self, conn: Connection, version: str, skip_setup: bool = False
|
|
73
91
|
):
|
|
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
|
-
)
|
|
82
92
|
appenv = f"""
|
|
83
93
|
set -a # Automatically export all variables
|
|
84
94
|
source .env
|
|
@@ -90,6 +100,7 @@ export PATH=".venv/bin:$PATH"
|
|
|
90
100
|
conn.run(f"echo '{appenv.strip()}' > {self.app_dir}/.appenv")
|
|
91
101
|
versioned_assets_dir = f"{self.app_dir}/v{version}"
|
|
92
102
|
if not skip_setup:
|
|
103
|
+
conn.run("rm -rf .venv")
|
|
93
104
|
conn.run("uv venv")
|
|
94
105
|
if self.config.requirements:
|
|
95
106
|
conn.run(f"uv pip install -r {versioned_assets_dir}/requirements.txt")
|
fujin/commands/init.py
CHANGED
|
@@ -8,7 +8,8 @@ import cappa
|
|
|
8
8
|
import tomli_w
|
|
9
9
|
|
|
10
10
|
from fujin.commands import BaseCommand
|
|
11
|
-
from fujin.config import
|
|
11
|
+
from fujin.config import InstallationMode
|
|
12
|
+
from fujin.config import tomllib
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
@cappa.command(help="Generate a sample configuration file")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import cappa
|
|
2
|
+
|
|
3
|
+
from fujin.commands import BaseCommand
|
|
4
|
+
from fujin.secrets import resolve_secrets
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@cappa.command(
|
|
8
|
+
help="Display the contents of the envfile with resolved secrets (for debugging purposes)"
|
|
9
|
+
)
|
|
10
|
+
class Printenv(BaseCommand):
|
|
11
|
+
def __call__(self):
|
|
12
|
+
if self.config.secret_config:
|
|
13
|
+
result = resolve_secrets(
|
|
14
|
+
self.config.host.envfile, self.config.secret_config
|
|
15
|
+
)
|
|
16
|
+
else:
|
|
17
|
+
result = self.config.host.envfile.read_text()
|
|
18
|
+
self.stdout.output(result)
|
fujin/commands/redeploy.py
CHANGED
|
@@ -5,8 +5,8 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
import cappa
|
|
7
7
|
|
|
8
|
-
from fujin.commands import BaseCommand
|
|
9
8
|
from .deploy import Deploy
|
|
9
|
+
from fujin.commands import BaseCommand
|
|
10
10
|
from fujin.config import InstallationMode
|
|
11
11
|
from fujin.connection import Connection
|
|
12
12
|
|
|
@@ -15,13 +15,17 @@ from fujin.connection import Connection
|
|
|
15
15
|
class Redeploy(BaseCommand):
|
|
16
16
|
def __call__(self):
|
|
17
17
|
deploy = Deploy()
|
|
18
|
+
parsed_env = deploy.parse_envfile()
|
|
18
19
|
deploy.build_app()
|
|
19
20
|
|
|
20
21
|
with self.app_environment() as conn:
|
|
21
22
|
hook_manager = self.create_hook_manager(conn)
|
|
22
23
|
hook_manager.pre_deploy()
|
|
23
|
-
deploy.
|
|
24
|
+
conn.run(f"mkdir -p {deploy.versioned_assets_dir}")
|
|
24
25
|
requirements_copied = self._copy_requirements_if_needed(conn)
|
|
26
|
+
deploy.transfer_files(
|
|
27
|
+
conn, env=parsed_env, skip_requirements=requirements_copied
|
|
28
|
+
)
|
|
25
29
|
deploy.install_project(conn, skip_setup=requirements_copied)
|
|
26
30
|
deploy.release(conn)
|
|
27
31
|
self.create_process_manager(conn).restart_services()
|
fujin/commands/rollback.py
CHANGED
fujin/commands/up.py
CHANGED
fujin/config.py
CHANGED
|
@@ -43,6 +43,23 @@ release_command
|
|
|
43
43
|
---------------
|
|
44
44
|
Optional command to run at the end of deployment (e.g., database migrations).
|
|
45
45
|
|
|
46
|
+
secrets
|
|
47
|
+
-------
|
|
48
|
+
|
|
49
|
+
Optional secrets configuration. If set, Fujin will load secrets from the specified secret management service.
|
|
50
|
+
Check out the `secrets </secrets.html>`_ page for more information.
|
|
51
|
+
|
|
52
|
+
adapter
|
|
53
|
+
~~~~~~~
|
|
54
|
+
The secret management service to use. Available options:
|
|
55
|
+
|
|
56
|
+
- ``bitwarden``
|
|
57
|
+
- ``1password``
|
|
58
|
+
|
|
59
|
+
password_env
|
|
60
|
+
~~~~~~~~~~~~
|
|
61
|
+
Environment variable containing the password for the service account. This is only required for certain adapters.
|
|
62
|
+
|
|
46
63
|
Webserver
|
|
47
64
|
---------
|
|
48
65
|
|
|
@@ -61,6 +78,10 @@ The address where your web application listens for requests. Supports any value
|
|
|
61
78
|
- HTTP address (e.g., ``localhost:8000``)
|
|
62
79
|
- Unix socket (e.g., ``unix//run/project.sock``)
|
|
63
80
|
|
|
81
|
+
certbot_email
|
|
82
|
+
~~~~~~~~~~~~~
|
|
83
|
+
Required when Nginx is used as a proxy, to obtain SSL certificates.
|
|
84
|
+
|
|
64
85
|
statics
|
|
65
86
|
~~~~~~~
|
|
66
87
|
|
|
@@ -162,7 +183,6 @@ from __future__ import annotations
|
|
|
162
183
|
|
|
163
184
|
import os
|
|
164
185
|
import sys
|
|
165
|
-
from functools import cached_property
|
|
166
186
|
from pathlib import Path
|
|
167
187
|
|
|
168
188
|
import msgspec
|
|
@@ -182,6 +202,16 @@ class InstallationMode(StrEnum):
|
|
|
182
202
|
BINARY = "binary"
|
|
183
203
|
|
|
184
204
|
|
|
205
|
+
class SecretAdapter(StrEnum):
|
|
206
|
+
BITWARDEN = "bitwarden"
|
|
207
|
+
ONE_PASSWORD = "1password"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class SecretConfig(msgspec.Struct):
|
|
211
|
+
adapter: SecretAdapter
|
|
212
|
+
password_env: str | None = None
|
|
213
|
+
|
|
214
|
+
|
|
185
215
|
class Config(msgspec.Struct, kw_only=True):
|
|
186
216
|
app_name: str = msgspec.field(name="app")
|
|
187
217
|
version: str = msgspec.field(default_factory=lambda: read_version_from_pyproject())
|
|
@@ -199,6 +229,7 @@ class Config(msgspec.Struct, kw_only=True):
|
|
|
199
229
|
requirements: str | None = None
|
|
200
230
|
hooks: HooksDict = msgspec.field(default_factory=dict)
|
|
201
231
|
local_config_dir: Path = Path(".fujin")
|
|
232
|
+
secret_config: SecretConfig | None = msgspec.field(name="secrets", default=None)
|
|
202
233
|
|
|
203
234
|
def __post_init__(self):
|
|
204
235
|
if self.installation_mode == InstallationMode.PY_PACKAGE:
|
|
@@ -281,6 +312,7 @@ class HostConfig(msgspec.Struct, kw_only=True):
|
|
|
281
312
|
class Webserver(msgspec.Struct):
|
|
282
313
|
upstream: str
|
|
283
314
|
type: str = "fujin.proxies.caddy"
|
|
315
|
+
certbot_email: str | None = None
|
|
284
316
|
statics: dict[str, str] = msgspec.field(default_factory=dict)
|
|
285
317
|
|
|
286
318
|
|
fujin/connection.py
CHANGED
|
@@ -8,11 +8,9 @@ import cappa
|
|
|
8
8
|
from fabric import Connection
|
|
9
9
|
from invoke import Responder
|
|
10
10
|
from invoke.exceptions import UnexpectedExit
|
|
11
|
-
from paramiko.ssh_exception import
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
SSHException,
|
|
15
|
-
)
|
|
11
|
+
from paramiko.ssh_exception import AuthenticationException
|
|
12
|
+
from paramiko.ssh_exception import NoValidConnectionsError
|
|
13
|
+
from paramiko.ssh_exception import SSHException
|
|
16
14
|
|
|
17
15
|
if TYPE_CHECKING:
|
|
18
16
|
from fujin.config import HostConfig
|
fujin/hooks.py
CHANGED
fujin/proxies/nginx.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import os
|
|
4
3
|
from pathlib import Path
|
|
5
4
|
|
|
6
5
|
import msgspec
|
|
@@ -8,8 +7,6 @@ import msgspec
|
|
|
8
7
|
from fujin.config import Config
|
|
9
8
|
from fujin.connection import Connection
|
|
10
9
|
|
|
11
|
-
CERTBOT_EMAIL = os.getenv("CERTBOT_EMAIL")
|
|
12
|
-
|
|
13
10
|
|
|
14
11
|
class WebProxy(msgspec.Struct):
|
|
15
12
|
conn: Connection
|
|
@@ -18,6 +15,7 @@ class WebProxy(msgspec.Struct):
|
|
|
18
15
|
upstream: str
|
|
19
16
|
statics: dict[str, str]
|
|
20
17
|
local_config_dir: Path
|
|
18
|
+
certbot_email: str | None = None
|
|
21
19
|
|
|
22
20
|
@property
|
|
23
21
|
def config_file(self) -> Path:
|
|
@@ -32,6 +30,7 @@ class WebProxy(msgspec.Struct):
|
|
|
32
30
|
app_name=config.app_name,
|
|
33
31
|
local_config_dir=config.local_config_dir,
|
|
34
32
|
statics=config.webserver.statics,
|
|
33
|
+
certbot_email=config.webserver.certbot_email,
|
|
35
34
|
)
|
|
36
35
|
|
|
37
36
|
def run_pty(self, *args, **kwargs):
|
|
@@ -63,13 +62,13 @@ class WebProxy(msgspec.Struct):
|
|
|
63
62
|
self.run_pty(
|
|
64
63
|
f"sudo ln -sf /etc/nginx/sites-available/{self.app_name}.conf /etc/nginx/sites-enabled/{self.app_name}.conf",
|
|
65
64
|
)
|
|
66
|
-
if
|
|
65
|
+
if self.certbot_email:
|
|
67
66
|
cert_path = f"/etc/letsencrypt/live/{self.domain_name}/fullchain.pem"
|
|
68
67
|
cert_exists = self.run_pty(f"sudo test -f {cert_path}", warn=True).ok
|
|
69
68
|
|
|
70
69
|
if not cert_exists:
|
|
71
70
|
self.run_pty(
|
|
72
|
-
f"sudo certbot --nginx -d {self.domain_name} --non-interactive --agree-tos --email {
|
|
71
|
+
f"sudo certbot --nginx -d {self.domain_name} --non-interactive --agree-tos --email {self.certbot_email} --redirect"
|
|
73
72
|
)
|
|
74
73
|
self.config_file.parent.mkdir(exist_ok=True)
|
|
75
74
|
self.conn.get(
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
import gevent
|
|
7
|
+
from dotenv import dotenv_values
|
|
8
|
+
|
|
9
|
+
from .bitwarden import bitwarden
|
|
10
|
+
from .onepassword import one_password
|
|
11
|
+
from fujin.config import SecretAdapter
|
|
12
|
+
from fujin.config import SecretConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
secret_reader = Callable[[str], str]
|
|
16
|
+
secret_adapter_context = Callable[[SecretConfig], secret_reader]
|
|
17
|
+
|
|
18
|
+
adapter_to_context: dict[SecretAdapter, secret_adapter_context] = {
|
|
19
|
+
SecretAdapter.BITWARDEN: bitwarden,
|
|
20
|
+
SecretAdapter.ONE_PASSWORD: one_password,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_secrets(envfile: Path, secret_config: SecretConfig) -> str:
|
|
25
|
+
env_dict = dotenv_values(envfile)
|
|
26
|
+
secrets = {key: value for key, value in env_dict.items() if value.startswith("$")}
|
|
27
|
+
adapter_context = adapter_to_context[secret_config.adapter]
|
|
28
|
+
parsed_secrets = {}
|
|
29
|
+
with adapter_context(secret_config) as secret_reader:
|
|
30
|
+
for key, secret in secrets.items():
|
|
31
|
+
parsed_secrets[key] = gevent.spawn(secret_reader, secret[1:])
|
|
32
|
+
gevent.joinall(parsed_secrets.values())
|
|
33
|
+
env_dict.update({key: thread.value for key, thread in parsed_secrets.items()})
|
|
34
|
+
return "\n".join(f'{key}="{value}"' for key, value in env_dict.items())
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from typing import Generator
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import cappa
|
|
10
|
+
|
|
11
|
+
from fujin.config import SecretConfig
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from . import secret_reader
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@contextmanager
|
|
18
|
+
def bitwarden(secret_config: SecretConfig) -> Generator[secret_reader, None, None]:
|
|
19
|
+
session = os.getenv("BW_SESSION")
|
|
20
|
+
if not session:
|
|
21
|
+
if not secret_config.password_env:
|
|
22
|
+
raise cappa.Exit(
|
|
23
|
+
"You need to set the password_env to use the bitwarden adapter or set the BW_SESSION environment variable",
|
|
24
|
+
code=1,
|
|
25
|
+
)
|
|
26
|
+
session = _signin(secret_config.password_env)
|
|
27
|
+
|
|
28
|
+
def read_secret(name: str) -> str:
|
|
29
|
+
result = subprocess.run(
|
|
30
|
+
[
|
|
31
|
+
"bw",
|
|
32
|
+
"get",
|
|
33
|
+
"password",
|
|
34
|
+
name,
|
|
35
|
+
"--raw",
|
|
36
|
+
"--session",
|
|
37
|
+
session,
|
|
38
|
+
"--nointeraction",
|
|
39
|
+
],
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
)
|
|
43
|
+
if result.returncode != 0:
|
|
44
|
+
raise cappa.Exit(f"Password not found for {name}")
|
|
45
|
+
return result.stdout.strip()
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
yield read_secret
|
|
49
|
+
finally:
|
|
50
|
+
pass
|
|
51
|
+
# subprocess.run(["bw", "lock"], capture_output=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _signin(password_env) -> str:
|
|
55
|
+
sync_result = subprocess.run(["bw", "sync"], capture_output=True, text=True)
|
|
56
|
+
if sync_result.returncode != 0:
|
|
57
|
+
raise cappa.Exit(f"Bitwarden sync failed: {sync_result.stdout}", code=1)
|
|
58
|
+
unlock_result = subprocess.run(
|
|
59
|
+
[
|
|
60
|
+
"bw",
|
|
61
|
+
"unlock",
|
|
62
|
+
"--nointeraction",
|
|
63
|
+
"--passwordenv",
|
|
64
|
+
password_env,
|
|
65
|
+
"--raw",
|
|
66
|
+
],
|
|
67
|
+
capture_output=True,
|
|
68
|
+
text=True,
|
|
69
|
+
)
|
|
70
|
+
if unlock_result.returncode != 0:
|
|
71
|
+
raise cappa.Exit(f"Bitwarden unlock failed {unlock_result.stderr}", code=1)
|
|
72
|
+
|
|
73
|
+
return unlock_result.stdout.strip()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from typing import Generator
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import cappa
|
|
9
|
+
|
|
10
|
+
from fujin.config import SecretConfig
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from . import secret_reader
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@contextmanager
|
|
17
|
+
def one_password(_: SecretConfig) -> Generator[secret_reader, None, None]:
|
|
18
|
+
def read_secret(name: str) -> str:
|
|
19
|
+
result = subprocess.run(["op", "read", name], capture_output=True, text=True)
|
|
20
|
+
if result.returncode != 0:
|
|
21
|
+
raise cappa.Exit(result.stderr)
|
|
22
|
+
return result.stdout.strip()
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
yield read_secret
|
|
26
|
+
finally:
|
|
27
|
+
pass
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: fujin-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: Get your project up and running in a few minutes on your own vps.
|
|
5
5
|
Project-URL: Documentation, https://github.com/falcopackages/fujin#readme
|
|
6
6
|
Project-URL: Issues, https://github.com/falcopackages/fujin/issues
|
|
@@ -21,36 +21,48 @@ Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
|
21
21
|
Requires-Python: >=3.10
|
|
22
22
|
Requires-Dist: cappa>=0.24
|
|
23
23
|
Requires-Dist: fabric>=3.2.2
|
|
24
|
+
Requires-Dist: gevent[recommended]>=24.11.1
|
|
24
25
|
Requires-Dist: msgspec[toml]>=0.18.6
|
|
26
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
25
27
|
Requires-Dist: rich>=13.9.2
|
|
26
28
|
Description-Content-Type: text/markdown
|
|
27
29
|
|
|
28
30
|
# fujin
|
|
29
31
|
|
|
32
|
+
> [!IMPORTANT]
|
|
33
|
+
> This tool is currently contains minimal features and is a work-in-progress
|
|
34
|
+
|
|
35
|
+
<!-- content:start -->
|
|
36
|
+
|
|
37
|
+
`fujin` is a simple deployment tool that helps you get your project up and running on a VPS in minutes. It manages your app processes using [systemd](https://systemd.io) and runs your apps behind [caddy](https://caddyserver.com).
|
|
38
|
+
|
|
39
|
+
[](https://github.com/falcopackages/fujin/actions/workflows/publish.yml)
|
|
30
40
|
[](https://pypi.org/project/fujin-cli)
|
|
31
41
|
[](https://pypi.org/project/fujin-cli)
|
|
32
42
|
[](https://github.com/falcopackages/fujin/blob/main/LICENSE.txt)
|
|
33
43
|
[](https://pypi.org/project/fujin-cli)
|
|
34
|
-
-----
|
|
35
44
|
|
|
36
|
-
|
|
37
|
-
> This package currently contains minimal features and is a work-in-progress
|
|
38
|
-
|
|
39
|
-
<!-- content:start -->
|
|
45
|
+
## Features
|
|
40
46
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
- 🚀 One-command server bootstrap
|
|
48
|
+
- 🔄 Rollback broken deployments
|
|
49
|
+
- 🔐 Zero configuration SSL certificates
|
|
50
|
+
- 🔁 Swappable proxy ([caddy](https://caddyserver.com), [nginx](https://nginx.org/en/) and `dummy` to disable proxy)
|
|
51
|
+
- 🛠️ Secrets injection from password managers ([Bitwarden](https://bitwarden.com/), [1Password](https://1password.com), etc.)
|
|
52
|
+
- 📝 Easily customizable `systemd` and `proxy` configurations
|
|
53
|
+
- 👨💻 Remote application management and log streaming
|
|
54
|
+
- 🐍 Supports packaged python apps and self-contained binaries
|
|
44
55
|
|
|
45
|
-
|
|
56
|
+
For more details, check out the [documentation📚](https://fujin.oluwatobi.dev/en/latest/).
|
|
46
57
|
|
|
47
58
|
## Why?
|
|
48
59
|
|
|
49
60
|
I wanted [kamal](https://kamal-deploy.org/) but without Docker, and I thought the idea was fun. At its core, this project automates versions of this [tutorial](https://www.digitalocean.com/community/tutorials/how-to-set-up-django-with-postgres-nginx-and-gunicorn-on-ubuntu). If you've been a Django beginner
|
|
50
|
-
trying to get your app in production, you probably went through this.
|
|
61
|
+
trying to get your app in production, you probably went through this.
|
|
62
|
+
|
|
63
|
+
I'm using `caddy` here by default instead of `nginx` because it's configurable via an API and it's is a no-brainer for SSL certificates. `Systemd` is the default on most Linux distributions and does a good enough job.
|
|
51
64
|
|
|
52
|
-
Fujin was initially planned to be a Python-only project, but the core concepts can be applied to any language that can produce a single distributable file (e.g., Go, Rust).
|
|
53
|
-
I'm currently rocking SQLite in production for my side projects and ths setup is enough for my use case.
|
|
65
|
+
Fujin was initially planned to be a Python-only project, but the core concepts can be applied to any language that can produce a single distributable file (e.g., Go, Rust).
|
|
54
66
|
|
|
55
67
|
The goal is to automate deployment while leaving you in full control of your Linux box. It's not a CLI PaaS - it's simple and expects you to be able to SSH into your server and troubleshoot if necessary. For beginners, it makes the initial deployment easier while you get your hands dirty with Linux.
|
|
56
68
|
If you need a never-break, worry-free, set-it-and-forget-it setup that auto-scales and does all the magic, fujin probably isn't for you.
|
|
@@ -66,4 +78,4 @@ Fujin draws inspiration from the following tools for their developer experience.
|
|
|
66
78
|
|
|
67
79
|
`fujin` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
|
|
68
80
|
|
|
69
|
-
<!-- content:end -->
|
|
81
|
+
<!-- content:end -->
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
fujin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
fujin/__main__.py,sha256=VJMBzuQuxkQaAKNEySktGnms944bkRsrIAjj-XeaWR8,1813
|
|
3
|
+
fujin/config.py,sha256=pdWK4fM3BITpz0MAv_ydCXRcQbgC3eLz--APdOpehiQ,10412
|
|
4
|
+
fujin/connection.py,sha256=LL7LhX9p0X9FmiGdlSroD3Ht216QY0Kd51xkSrXmM3s,2479
|
|
5
|
+
fujin/errors.py,sha256=74Rh-Sgql1YspPdR_akQ2G3xZ48zecyafYCptpaFo1A,73
|
|
6
|
+
fujin/hooks.py,sha256=nTn2PHpFuKbl_iAXUxagkrGcuwT2Exx8zIq4YfleX8A,1351
|
|
7
|
+
fujin/commands/__init__.py,sha256=g0b13vzidPUbxne_Zo_Wv5jpEmwCiSK4AokCinH9uo4,39
|
|
8
|
+
fujin/commands/_base.py,sha256=NfBdRBx_l7tYnVg0L8A3tPFC_XmtuHq-toU66KT5CiE,2553
|
|
9
|
+
fujin/commands/app.py,sha256=mazb4dCTdR5juh79bL3a9b68Nd6O8u_nR9IgYqQlqWE,5279
|
|
10
|
+
fujin/commands/config.py,sha256=xdfd1OZLxw2YZldiAbW5rq5EBXEaXbUC-I7FKLRfzIQ,2387
|
|
11
|
+
fujin/commands/deploy.py,sha256=PfEdLS-rkdF12BfjtLEyqbnn1K4dEYRh7h6CFtWb2zs,5934
|
|
12
|
+
fujin/commands/docs.py,sha256=b5FZ8AgoAfn4q4BueEQvM2w5HCuh8-rwBqv_CRFVU8E,349
|
|
13
|
+
fujin/commands/down.py,sha256=v1lAq70ApktjeHRB_1sCzjmKH8t6EXqyL4RTt7OE-f0,1716
|
|
14
|
+
fujin/commands/init.py,sha256=zREfkIQyC6etqqQ6hgvDqpNNWQT4bk_8IOPBBT5-YUE,3983
|
|
15
|
+
fujin/commands/printenv.py,sha256=bpGmOfc1t_dKWb8gy7EILYtwEyI9pIwhKg2XPKyJ9cQ,527
|
|
16
|
+
fujin/commands/proxy.py,sha256=ajXwboS0gDDiMWW7b9rtWU6WPF1h7JYYeycDyU-hQfg,3053
|
|
17
|
+
fujin/commands/prune.py,sha256=C2aAN6AUS84jgRg1eiCroyiuZyaZDmf5yvGAQY9xkcg,1517
|
|
18
|
+
fujin/commands/redeploy.py,sha256=z1giY9SpINdJt8UagPlvUkJu30c8fgqapNqOxG4Jfuo,2378
|
|
19
|
+
fujin/commands/rollback.py,sha256=JsocJzQcdQelSnYD94klhjBh8UKkkdiRD9shfUfo4FI,2032
|
|
20
|
+
fujin/commands/server.py,sha256=0N_P_Luj31t56riZ8GfgRqW3vRHiw0cDrlp3PFoyWn8,3453
|
|
21
|
+
fujin/commands/up.py,sha256=OEK_n-6-mnnIUffFpR7QtVunr1V1F04pxlAAS1U62BY,419
|
|
22
|
+
fujin/process_managers/__init__.py,sha256=MhhfTBhm64zWRAKgjvsZRIToOUJus60vGScbAjqpQ6Y,994
|
|
23
|
+
fujin/process_managers/systemd.py,sha256=qG_4Ew8SEWtaTFOAW_XZXsMO2WjFWZ4dp5nBwAPBObk,5603
|
|
24
|
+
fujin/proxies/__init__.py,sha256=UuWYU175tkdaz1WWRCDDpQgGfFVYYNR9PBxA3lTCNr0,695
|
|
25
|
+
fujin/proxies/caddy.py,sha256=dzLD8s664_kIK-1hCE3y50JIwBd8kK9yS1LynUDRVSE,7908
|
|
26
|
+
fujin/proxies/dummy.py,sha256=qBKSn8XNEA9SVwB7GzRNX2l9Iw6tUjo2CFqZjWi0FjY,465
|
|
27
|
+
fujin/proxies/nginx.py,sha256=BNJNLxLLRVAmBIGVCk8pb16iiSJsOI9jXOZhdSQGtX8,4151
|
|
28
|
+
fujin/secrets/__init__.py,sha256=p4rY4J7yRoEEz6OXkomJ_Ov2AaaQ37-Zd_TJGpUDPgQ,1217
|
|
29
|
+
fujin/secrets/bitwarden.py,sha256=01GZL5hYwZzL6yXy5ab3L3kgBFBeOT8i3Yg9GC8YwFU,2008
|
|
30
|
+
fujin/secrets/onepassword.py,sha256=6Xj3XWttKfcjMbcoMZvXVpJW1KHxlD785DysmX_mqvk,654
|
|
31
|
+
fujin/templates/simple.service,sha256=-lyKjmSyfHGucP4O_vRQE1NNaHq0Qjsc0twdwoRLgI0,321
|
|
32
|
+
fujin/templates/web.service,sha256=NZ7ZeaFvV_MZTBn8QqRQeu8PIrWHf3aWYWNzjOQeqCw,685
|
|
33
|
+
fujin/templates/web.socket,sha256=2lJsiOHlMJL0YlN7YBLLnr5zqsytPEt81yP34nk0dmc,173
|
|
34
|
+
fujin_cli-0.7.1.dist-info/METADATA,sha256=kqTIyfQ_EtbpdoaSkBmrF5Wy6y838HLaqLJHbLko3L4,4576
|
|
35
|
+
fujin_cli-0.7.1.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
|
|
36
|
+
fujin_cli-0.7.1.dist-info/entry_points.txt,sha256=Y_TBtKt3j11qhwquMexZR5yqnDEqOBDACtresqQFE-s,46
|
|
37
|
+
fujin_cli-0.7.1.dist-info/licenses/LICENSE.txt,sha256=0QF8XfuH0zkIHhSet6teXfiCze6JSdr8inRkmLLTDyo,1099
|
|
38
|
+
fujin_cli-0.7.1.dist-info/RECORD,,
|
fujin/commands/secrets.py
DELETED
fujin_cli-0.6.0.dist-info/RECORD
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
fujin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
fujin/__main__.py,sha256=St0VnEWhRRw_ukAddAwDGFliLqQT3xlone-9JIONlDI,1702
|
|
3
|
-
fujin/config.py,sha256=GecU6XklYNXjjR7bkRORBcX1-AYlIaTMw1mmYKSCSnA,9595
|
|
4
|
-
fujin/connection.py,sha256=ZkYaNykRFj9Yr-K-vOrZtVVGUDurDm6W7OQrgct71CA,2428
|
|
5
|
-
fujin/errors.py,sha256=74Rh-Sgql1YspPdR_akQ2G3xZ48zecyafYCptpaFo1A,73
|
|
6
|
-
fujin/hooks.py,sha256=QHIqxLxujG2U70UkN1BpUplE6tTqn7pFJP5oHde1tUQ,1350
|
|
7
|
-
fujin/commands/__init__.py,sha256=uIGGXt8YofL5RZn8KIy153ioWGoCl32ffHtqOhB-6ZM,78
|
|
8
|
-
fujin/commands/_base.py,sha256=o3R4-c3XeFWTIW3stiUdrcCPwdjzfjUVIpZy2L1-gZ4,2525
|
|
9
|
-
fujin/commands/app.py,sha256=mazb4dCTdR5juh79bL3a9b68Nd6O8u_nR9IgYqQlqWE,5279
|
|
10
|
-
fujin/commands/config.py,sha256=xdfd1OZLxw2YZldiAbW5rq5EBXEaXbUC-I7FKLRfzIQ,2387
|
|
11
|
-
fujin/commands/deploy.py,sha256=oAOLUrtHjHkmIA3D0AiaJRWEsfVk51_I29kRC_FhjYo,5463
|
|
12
|
-
fujin/commands/docs.py,sha256=b5FZ8AgoAfn4q4BueEQvM2w5HCuh8-rwBqv_CRFVU8E,349
|
|
13
|
-
fujin/commands/down.py,sha256=v1lAq70ApktjeHRB_1sCzjmKH8t6EXqyL4RTt7OE-f0,1716
|
|
14
|
-
fujin/commands/init.py,sha256=t8uwwOi4SBqHjV8px_SkTHAeZIiIUJnFN-lf7DK6HhE,3959
|
|
15
|
-
fujin/commands/proxy.py,sha256=ajXwboS0gDDiMWW7b9rtWU6WPF1h7JYYeycDyU-hQfg,3053
|
|
16
|
-
fujin/commands/prune.py,sha256=C2aAN6AUS84jgRg1eiCroyiuZyaZDmf5yvGAQY9xkcg,1517
|
|
17
|
-
fujin/commands/redeploy.py,sha256=JvCJBZBcCKkUw1efZwRPJMLUAV8oqBAZeSbUBLHyn3k,2185
|
|
18
|
-
fujin/commands/rollback.py,sha256=BN9vOTEBcSSpFIfck9nzWvMVO7asVC20lQbcNrxRchg,2009
|
|
19
|
-
fujin/commands/secrets.py,sha256=1xZQVkvbopsAcWUocLstxPKxsvmGoE2jWip5hdTrP50,162
|
|
20
|
-
fujin/commands/server.py,sha256=0N_P_Luj31t56riZ8GfgRqW3vRHiw0cDrlp3PFoyWn8,3453
|
|
21
|
-
fujin/commands/up.py,sha256=DgDN-1mc_mMHJRCIvcB947Cd5a7phunu9NpXloGK0UU,419
|
|
22
|
-
fujin/process_managers/__init__.py,sha256=MhhfTBhm64zWRAKgjvsZRIToOUJus60vGScbAjqpQ6Y,994
|
|
23
|
-
fujin/process_managers/systemd.py,sha256=qG_4Ew8SEWtaTFOAW_XZXsMO2WjFWZ4dp5nBwAPBObk,5603
|
|
24
|
-
fujin/proxies/__init__.py,sha256=UuWYU175tkdaz1WWRCDDpQgGfFVYYNR9PBxA3lTCNr0,695
|
|
25
|
-
fujin/proxies/caddy.py,sha256=dzLD8s664_kIK-1hCE3y50JIwBd8kK9yS1LynUDRVSE,7908
|
|
26
|
-
fujin/proxies/dummy.py,sha256=qBKSn8XNEA9SVwB7GzRNX2l9Iw6tUjo2CFqZjWi0FjY,465
|
|
27
|
-
fujin/proxies/nginx.py,sha256=S2-tBaytGtehqMyeZZMPSPoXjV1GVv7S63eMtfhkGNM,4100
|
|
28
|
-
fujin/templates/simple.service,sha256=-lyKjmSyfHGucP4O_vRQE1NNaHq0Qjsc0twdwoRLgI0,321
|
|
29
|
-
fujin/templates/web.service,sha256=NZ7ZeaFvV_MZTBn8QqRQeu8PIrWHf3aWYWNzjOQeqCw,685
|
|
30
|
-
fujin/templates/web.socket,sha256=2lJsiOHlMJL0YlN7YBLLnr5zqsytPEt81yP34nk0dmc,173
|
|
31
|
-
fujin_cli-0.6.0.dist-info/METADATA,sha256=_rYue3Q7z_StHy_Z9DVdaU0RWijde8eWFlm_gwxN4GY,4452
|
|
32
|
-
fujin_cli-0.6.0.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
|
|
33
|
-
fujin_cli-0.6.0.dist-info/entry_points.txt,sha256=Y_TBtKt3j11qhwquMexZR5yqnDEqOBDACtresqQFE-s,46
|
|
34
|
-
fujin_cli-0.6.0.dist-info/licenses/LICENSE.txt,sha256=0QF8XfuH0zkIHhSet6teXfiCze6JSdr8inRkmLLTDyo,1099
|
|
35
|
-
fujin_cli-0.6.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|