fujin-cli 0.25.2__tar.gz → 0.26.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.
Files changed (155) hide show
  1. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/CHANGELOG.md +11 -0
  2. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/PKG-INFO +1 -1
  3. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/configuration.rst +71 -0
  4. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/justfile +1 -1
  5. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/plugins/fujin-secrets-1password/pyproject.toml +2 -2
  6. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/plugins/fujin-secrets-bitwarden/pyproject.toml +2 -2
  7. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/plugins/fujin-secrets-doppler/pyproject.toml +2 -2
  8. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/plugins/fujin-secrets-env/pyproject.toml +2 -2
  9. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/pyproject.toml +2 -2
  10. fujin_cli-0.26.0/src/fujin/__init__.py +1 -0
  11. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/_installer.py +65 -1
  12. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/commands/deploy.py +123 -22
  13. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/config.py +15 -3
  14. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/connection.py +74 -61
  15. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/uv.lock +5 -5
  16. fujin_cli-0.25.2/src/fujin/__init__.py +0 -1
  17. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/.github/FUNDING.yml +0 -0
  18. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/.github/workflows/publish.yml +0 -0
  19. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/.github/workflows/test.yml +0 -0
  20. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/.gitignore +0 -0
  21. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/.pre-commit-config.yaml +0 -0
  22. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/.readthedocs.yaml +0 -0
  23. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/CLAUDE.md +0 -0
  24. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/LICENSE.txt +0 -0
  25. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/README.md +0 -0
  26. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/Vagrantfile +0 -0
  27. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/app-cat-help.png +0 -0
  28. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/app-exec-help.png +0 -0
  29. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/app-help.png +0 -0
  30. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/app-logs-help.png +0 -0
  31. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/app-restart-help.png +0 -0
  32. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/app-scale-help.png +0 -0
  33. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/app-shell-help.png +0 -0
  34. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/app-start-help.png +0 -0
  35. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/app-status-help.png +0 -0
  36. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/app-stop-help.png +0 -0
  37. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/audit-help.png +0 -0
  38. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/deploy-help.png +0 -0
  39. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/down-help.png +0 -0
  40. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/fa-help.png +0 -0
  41. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/fujin-help.png +0 -0
  42. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/init-help.png +0 -0
  43. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/migrate-help.png +0 -0
  44. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/new-help.png +0 -0
  45. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/prune-help.png +0 -0
  46. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/rollback-help.png +0 -0
  47. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/server-bootstrap-help.png +0 -0
  48. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/server-create-user-help.png +0 -0
  49. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/server-exec-help.png +0 -0
  50. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/server-help.png +0 -0
  51. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/server-setup-ssh-help.png +0 -0
  52. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/server-status-help.png +0 -0
  53. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/_static/images/help/up-help.png +0 -0
  54. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/changelog.rst +0 -0
  55. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/commands/app.rst +0 -0
  56. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/commands/audit.rst +0 -0
  57. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/commands/deploy.rst +0 -0
  58. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/commands/down.rst +0 -0
  59. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/commands/index.rst +0 -0
  60. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/commands/init.rst +0 -0
  61. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/commands/migrate.rst +0 -0
  62. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/commands/new.rst +0 -0
  63. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/commands/prune.rst +0 -0
  64. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/commands/rollback.rst +0 -0
  65. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/commands/server.rst +0 -0
  66. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/commands/up.rst +0 -0
  67. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/conf.py +0 -0
  68. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/guides/django-complete.rst +0 -0
  69. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/guides/index.rst +0 -0
  70. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/guides/templates.rst +0 -0
  71. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/howtos/binary.rst +0 -0
  72. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/howtos/django.rst +0 -0
  73. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/howtos/index.rst +0 -0
  74. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/index.rst +0 -0
  75. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/installation.rst +0 -0
  76. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/integrations.rst +0 -0
  77. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/requirements.txt +0 -0
  78. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/secrets.rst +0 -0
  79. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/docs/troubleshooting.rst +0 -0
  80. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/examples/django/bookstore/.fujin/Caddyfile +0 -0
  81. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/examples/django/bookstore/.fujin/systemd/health.service +0 -0
  82. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/examples/django/bookstore/.fujin/systemd/health.timer +0 -0
  83. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/examples/django/bookstore/.fujin/systemd/web.service +0 -0
  84. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/examples/django/bookstore/.fujin/systemd/worker@.service +0 -0
  85. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/examples/django/bookstore/README.md +0 -0
  86. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/examples/django/bookstore/bookstore/__init__.py +0 -0
  87. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/examples/django/bookstore/bookstore/__main__.py +0 -0
  88. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/examples/django/bookstore/bookstore/asgi.py +0 -0
  89. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/examples/django/bookstore/bookstore/management/commands/health.py +0 -0
  90. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/examples/django/bookstore/bookstore/settings.py +0 -0
  91. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/examples/django/bookstore/bookstore/urls.py +0 -0
  92. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/examples/django/bookstore/bookstore/wsgi.py +0 -0
  93. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/examples/django/bookstore/fujin.toml +0 -0
  94. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/examples/django/bookstore/manage.py +0 -0
  95. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/examples/django/bookstore/pyproject.toml +0 -0
  96. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/examples/django/bookstore/requirements.txt +0 -0
  97. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/plugins/fujin-secrets-1password/README.md +0 -0
  98. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/plugins/fujin-secrets-1password/src/fujin_secrets_1password/__init__.py +0 -0
  99. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/plugins/fujin-secrets-1password/src/fujin_secrets_1password/py.typed +0 -0
  100. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/plugins/fujin-secrets-bitwarden/README.md +0 -0
  101. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/plugins/fujin-secrets-bitwarden/src/fujin_secrets_bitwarden/__init__.py +0 -0
  102. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/plugins/fujin-secrets-bitwarden/src/fujin_secrets_bitwarden/py.typed +0 -0
  103. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/plugins/fujin-secrets-doppler/README.md +0 -0
  104. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/plugins/fujin-secrets-doppler/src/fujin_secrets_doppler/__init__.py +0 -0
  105. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/plugins/fujin-secrets-doppler/src/fujin_secrets_doppler/py.typed +0 -0
  106. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/plugins/fujin-secrets-env/README.md +0 -0
  107. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/plugins/fujin-secrets-env/src/fujin_secrets_env/__init__.py +0 -0
  108. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/__main__.py +0 -0
  109. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/audit.py +0 -0
  110. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/caddy.py +0 -0
  111. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/commands/__init__.py +0 -0
  112. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/commands/_base.py +0 -0
  113. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/commands/app.py +0 -0
  114. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/commands/audit.py +0 -0
  115. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/commands/down.py +0 -0
  116. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/commands/init.py +0 -0
  117. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/commands/migrate.py +0 -0
  118. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/commands/new.py +0 -0
  119. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/commands/prune.py +0 -0
  120. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/commands/rollback.py +0 -0
  121. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/commands/server.py +0 -0
  122. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/commands/showenv.py +0 -0
  123. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/commands/up.py +0 -0
  124. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/discovery.py +0 -0
  125. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/errors.py +0 -0
  126. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/fa.py +0 -0
  127. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/formatting.py +0 -0
  128. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/secrets.py +0 -0
  129. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/src/fujin/templates.py +0 -0
  130. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/__init__.py +0 -0
  131. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/conftest.py +0 -0
  132. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/integration/Dockerfile +0 -0
  133. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/integration/__init__.py +0 -0
  134. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/integration/conftest.py +0 -0
  135. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/integration/helpers.py +0 -0
  136. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/integration/test_app_management.py +0 -0
  137. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/integration/test_full_deploy.py +0 -0
  138. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/integration/test_installation.py +0 -0
  139. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/integration/test_server_bootstrap.py +0 -0
  140. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/test_app.py +0 -0
  141. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/test_audit.py +0 -0
  142. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/test_caddy_domain.py +0 -0
  143. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/test_config.py +0 -0
  144. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/test_connection.py +0 -0
  145. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/test_deploy.py +0 -0
  146. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/test_discovery.py +0 -0
  147. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/test_down.py +0 -0
  148. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/test_init.py +0 -0
  149. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/test_new.py +0 -0
  150. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/test_prune.py +0 -0
  151. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/test_rollback.py +0 -0
  152. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/test_scale.py +0 -0
  153. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/test_secrets.py +0 -0
  154. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/test_server.py +0 -0
  155. {fujin_cli-0.25.2 → fujin_cli-0.26.0}/tests/test_up.py +0 -0
@@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.26.0] - 2026-06-05
8
+
9
+ ### 🚀 Features
10
+
11
+ - Hooks system
12
+ - Improve reliability
13
+
14
+ ### ⚡ Performance
15
+
16
+ - Ssh and deploy units cache
17
+
7
18
  ## [0.25.2] - 2026-03-31
8
19
 
9
20
  ### 🚀 Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fujin-cli
3
- Version: 0.25.2
3
+ Version: 0.26.0
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/Tobi-De/fujin#readme
6
6
  Project-URL: Issues, https://github.com/Tobi-De/fujin/issues
@@ -177,6 +177,77 @@ For static files (Django example):
177
177
 
178
178
  The ``{app_dir}`` variable is substituted during deployment.
179
179
 
180
+ Hooks
181
+ -----
182
+
183
+ Deployment hooks let you run custom commands at specific points during the deploy and rollback lifecycle. They are defined in the ``[hooks]`` section of ``fujin.toml``.
184
+
185
+ Hook Phases
186
+ ~~~~~~~~~~~
187
+
188
+ **pre-install**
189
+ Runs before the installer, as the deploy (SSH) user. Use for infrastructure setup like installing packages or creating databases. Has access to environment variables from ``.env``. **Failure is fatal** — the deploy stops and rolls back.
190
+
191
+ .. code-block:: toml
192
+ :caption: fujin.toml
193
+
194
+ [hooks]
195
+ pre_install = [
196
+ "sudo apt-get install -y postgresql",
197
+ "sudo -u postgres createdb myapp",
198
+ ]
199
+
200
+ **post-install**
201
+ Runs after the package is installed but before systemd services are started. Executes as ``app_user`` via ``sudo -u``, within the app's environment (``.appenv`` sourced, so the app binary is on ``PATH``). Use for database migrations. **Failure is fatal** — the deploy stops.
202
+
203
+ .. code-block:: toml
204
+ :caption: fujin.toml
205
+
206
+ [hooks]
207
+ post_install = [
208
+ "python manage.py migrate --noinput",
209
+ ]
210
+
211
+ **post-start**
212
+ Runs after services are confirmed running, before Caddy configuration. Executes as ``app_user`` within the app environment. Use for static file collection, cache warming, or health checks. **Failure is non-fatal** — services are already running, a warning is logged but the deploy succeeds.
213
+
214
+ .. code-block:: toml
215
+ :caption: fujin.toml
216
+
217
+ [hooks]
218
+ post_start = [
219
+ "python manage.py collectstatic --noinput",
220
+ ]
221
+
222
+ Available Variables
223
+ ~~~~~~~~~~~~~~~~~~
224
+
225
+ Hook commands support the same template variables as systemd unit files:
226
+
227
+ - ``{app_name}`` - Application name
228
+ - ``{app_dir}`` - Full path to application directory (``/opt/fujin/{app_name}``)
229
+ - ``{app_user}`` - User running the app
230
+ - ``{version}`` - Current version being deployed
231
+ - ``{install_dir}`` - Path to ``.install`` directory
232
+
233
+ Complete Example
234
+ ~~~~~~~~~~~~~~~~
235
+
236
+ .. code-block:: toml
237
+ :caption: fujin.toml
238
+
239
+ [hooks]
240
+ pre_install = [
241
+ "sudo -u postgres createdb myapp",
242
+ ]
243
+ post_install = [
244
+ "python manage.py migrate --noinput",
245
+ ]
246
+ post_start = [
247
+ "python manage.py collectstatic --noinput",
248
+ "python manage.py clearsessions",
249
+ ]
250
+
180
251
  Host Configuration
181
252
  -------------------
182
253
 
@@ -1,4 +1,4 @@
1
- set dotenv-load := true
1
+ set dotenv-load
2
2
 
3
3
  # List all available commands
4
4
  _default:
@@ -4,7 +4,7 @@ requires = [ "uv-build>=0.9.18,<0.10" ]
4
4
 
5
5
  [project]
6
6
  name = "fujin-secrets-1password"
7
- version = "0.25.2"
7
+ version = "0.26.0"
8
8
  description = "1Password secret adapter for Fujin"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -20,7 +20,7 @@ classifiers = [
20
20
  "Programming Language :: Python :: 3.14",
21
21
  ]
22
22
  dependencies = [
23
- "fujin-cli>=0.25.2",
23
+ "fujin-cli>=0.26",
24
24
  "python-dotenv>=1.0.1",
25
25
  ]
26
26
 
@@ -4,7 +4,7 @@ requires = [ "uv-build>=0.9.18,<0.10" ]
4
4
 
5
5
  [project]
6
6
  name = "fujin-secrets-bitwarden"
7
- version = "0.25.2"
7
+ version = "0.26.0"
8
8
  description = "Bitwarden secret adapter for Fujin"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -20,7 +20,7 @@ classifiers = [
20
20
  "Programming Language :: Python :: 3.14",
21
21
  ]
22
22
  dependencies = [
23
- "fujin-cli>=0.25.2",
23
+ "fujin-cli>=0.26",
24
24
  "python-dotenv>=1.0.1",
25
25
  ]
26
26
 
@@ -4,7 +4,7 @@ requires = [ "uv-build>=0.9.18,<0.10" ]
4
4
 
5
5
  [project]
6
6
  name = "fujin-secrets-doppler"
7
- version = "0.25.2"
7
+ version = "0.26.0"
8
8
  description = "Doppler secret adapter for Fujin"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -20,7 +20,7 @@ classifiers = [
20
20
  "Programming Language :: Python :: 3.14",
21
21
  ]
22
22
  dependencies = [
23
- "fujin-cli>=0.25.2",
23
+ "fujin-cli>=0.26",
24
24
  "python-dotenv>=1.0.1",
25
25
  ]
26
26
 
@@ -4,7 +4,7 @@ requires = [ "uv-build>=0.9.18,<0.10" ]
4
4
 
5
5
  [project]
6
6
  name = "fujin-secrets-env"
7
- version = "0.25.2"
7
+ version = "0.26.0"
8
8
  description = "Environment variable secret adapter for Fujin"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -20,7 +20,7 @@ classifiers = [
20
20
  "Programming Language :: Python :: 3.14",
21
21
  ]
22
22
  dependencies = [
23
- "fujin-cli>=0.25.2",
23
+ "fujin-cli>=0.26",
24
24
  "python-dotenv>=1.0.1",
25
25
  ]
26
26
 
@@ -5,7 +5,7 @@ requires = [ "hatchling" ]
5
5
 
6
6
  [project]
7
7
  name = "fujin-cli"
8
- version = "0.25.2"
8
+ version = "0.26.0"
9
9
  description = "Get your project up and running in a few minutes on your own vps."
10
10
  readme = "README.md"
11
11
  keywords = [
@@ -151,7 +151,7 @@ markers = [
151
151
  ]
152
152
 
153
153
  [tool.bumpversion]
154
- current_version = "0.25.2"
154
+ current_version = "0.26.0"
155
155
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
156
156
  serialize = [ "{major}.{minor}.{patch}" ]
157
157
  search = "{current_version}"
@@ -0,0 +1 @@
1
+ __version__ = "0.26.0"
@@ -13,7 +13,7 @@ import sys
13
13
  import tempfile
14
14
  import time
15
15
  import zipfile
16
- from dataclasses import dataclass
16
+ from dataclasses import dataclass, field
17
17
  from itertools import chain
18
18
  from pathlib import Path
19
19
  from typing import Literal, TypedDict
@@ -62,6 +62,7 @@ class InstallConfig:
62
62
  caddy_config_path: str
63
63
  app_bin: str
64
64
  deployed_units: list[DeployedUnit]
65
+ hooks: dict[str, list[str]] = field(default_factory=dict)
65
66
 
66
67
  @property
67
68
  def uv_path(self) -> str:
@@ -102,6 +103,30 @@ def _setup_logging(verbose: int) -> None:
102
103
  logger.setLevel(level)
103
104
 
104
105
 
106
+ def _run_hooks(config: InstallConfig, phase: str, *, fatal: bool = True) -> None:
107
+ commands = config.hooks.get(phase, [])
108
+ if not commands:
109
+ return
110
+
111
+ logger.info("Running %s hooks...", phase)
112
+ install_dir = f"{config.app_dir}/.install"
113
+ for cmd in commands:
114
+ logger.info(" [%s] %s", phase, cmd)
115
+ full_cmd = f"sudo -u {shlex.quote(config.app_user)} bash -c " + shlex.quote(
116
+ f"source {install_dir}/.appenv 2>/dev/null ; {cmd}"
117
+ )
118
+ try:
119
+ run(full_cmd, check=True, timeout=300)
120
+ except subprocess.TimeoutExpired:
121
+ if fatal:
122
+ raise
123
+ logger.warning("[%s] Hook timed out: %s", phase, cmd)
124
+ except subprocess.CalledProcessError as e:
125
+ if fatal:
126
+ raise
127
+ logger.warning("[%s] Hook failed (exit %d): %s", phase, e.returncode, cmd)
128
+
129
+
105
130
  def install(
106
131
  config: InstallConfig, bundle_dir: Path, *, full_restart: bool = False
107
132
  ) -> None:
@@ -226,6 +251,11 @@ export -f {config.app_name}
226
251
  run(f"chown {config.deploy_user}:{config.app_user} {app_dir}")
227
252
  app_dir.chmod(0o775)
228
253
 
254
+ # ==========================================================================
255
+ # PHASE 2.5: POST-INSTALL HOOKS
256
+ # ==========================================================================
257
+ _run_hooks(config, "post_install", fatal=True)
258
+
229
259
  # ==========================================================================
230
260
  # PHASE 3: CONFIGURING SYSTEMD SERVICES
231
261
  # ==========================================================================
@@ -350,6 +380,35 @@ export -f {config.app_name}
350
380
  if unit["template_timer_name"]:
351
381
  active_units.append(unit["template_timer_name"])
352
382
 
383
+ # Validate all unit files before enabling/starting (catch errors early)
384
+ logger.info("Validating systemd unit files...")
385
+ validation_issues = 0
386
+ for unit in config.deployed_units:
387
+ unit_path = SYSTEMD_SYSTEM_DIR / unit["template_service_name"]
388
+ if not unit_path.exists():
389
+ continue
390
+ result = subprocess.run(
391
+ f"systemd-analyze verify {shlex.quote(str(unit_path))}",
392
+ shell=True,
393
+ capture_output=True,
394
+ text=True,
395
+ )
396
+ if result.returncode != 0 or result.stderr.strip():
397
+ validation_issues += 1
398
+ logger.warning(
399
+ "Validation issues in %s (exit code %d):",
400
+ unit_path.name,
401
+ result.returncode,
402
+ )
403
+ for line in result.stderr.splitlines():
404
+ logger.warning(" %s", line)
405
+
406
+ if validation_issues:
407
+ logger.warning(
408
+ "%d unit(s) have validation warnings - review above before continuing",
409
+ validation_issues,
410
+ )
411
+
353
412
  units_str = " ".join(active_units)
354
413
  run(
355
414
  f"systemctl daemon-reload && systemctl enable {units_str}",
@@ -423,6 +482,11 @@ export -f {config.app_name}
423
482
  )
424
483
  sys.exit(EXIT_SERVICE_START_FAILED)
425
484
 
485
+ # ==========================================================================
486
+ # PHASE 3.5: POST-START HOOKS
487
+ # ==========================================================================
488
+ _run_hooks(config, "post_start", fatal=False)
489
+
426
490
  # ==========================================================================
427
491
  # PHASE 4: CADDY CONFIGURATION
428
492
  # ==========================================================================
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import base64
4
3
  import json
5
4
  import logging
6
5
  import shlex
@@ -25,6 +24,7 @@ from fujin.audit import log_operation
25
24
  from fujin.commands import BaseCommand
26
25
  from fujin.commands.rollback import Rollback
27
26
  from fujin.config import get_git_short_hash
27
+ from fujin.connection import SSH2Connection
28
28
  from fujin.errors import (
29
29
  BuildError,
30
30
  CommandError,
@@ -94,7 +94,26 @@ class Deploy(BaseCommand):
94
94
  try:
95
95
  logger.debug("Build command: %s", self.config.build_command)
96
96
  self.output.info(f"Building application ...")
97
- subprocess.run(self.config.build_command, check=True, shell=True)
97
+ subprocess.run(
98
+ self.config.build_command,
99
+ check=True,
100
+ shell=True,
101
+ timeout=self.config.build_timeout,
102
+ )
103
+ except subprocess.TimeoutExpired:
104
+ self.output.error(
105
+ f"Build command timed out after {self.config.build_timeout}s"
106
+ )
107
+ self.output.info(
108
+ f"Command: {self.config.build_command}\n\n"
109
+ "Troubleshooting:\n"
110
+ " - The build is taking too long; increase build_timeout in fujin.toml\n"
111
+ " - Check for infinite loops or hung subprocesses in your build\n"
112
+ " - Try running the build command manually to see its progress"
113
+ )
114
+ raise BuildError(
115
+ "Build timed out", command=self.config.build_command
116
+ ) from None
98
117
  except subprocess.CalledProcessError as e:
99
118
  self.output.error(f"Build command failed with exit code {e.returncode}")
100
119
  self.output.info(
@@ -274,6 +293,23 @@ class Deploy(BaseCommand):
274
293
  all_unresolved.update(unresolved)
275
294
  (zipapp_dir / "Caddyfile").write_text(resolved_caddyfile)
276
295
 
296
+ # Resolve hook commands
297
+ resolved_hooks: dict[str, list[str]] = {}
298
+ if self.config.hooks_config:
299
+ for phase in (
300
+ "pre_install",
301
+ "post_install",
302
+ "post_start",
303
+ ):
304
+ commands = getattr(self.config.hooks_config, phase)
305
+ resolved_commands = []
306
+ for cmd in commands:
307
+ resolved, unresolved = safe_format(cmd, **context)
308
+ all_unresolved.update(unresolved)
309
+ resolved_commands.append(resolved)
310
+ if resolved_commands:
311
+ resolved_hooks[phase] = resolved_commands
312
+
277
313
  if all_unresolved:
278
314
  self.output.warning(
279
315
  f"Found unresolved variables in configuration files: {', '.join(sorted(all_unresolved))}\n"
@@ -295,6 +331,7 @@ class Deploy(BaseCommand):
295
331
  "caddy_config_path": self.config.caddy_config_path,
296
332
  "app_bin": self.config.app_name, # Just the binary name, not full path
297
333
  "deployed_units": deployed_units_data,
334
+ "hooks": resolved_hooks,
298
335
  }
299
336
 
300
337
  # Write config without indent for smaller size
@@ -325,7 +362,7 @@ class Deploy(BaseCommand):
325
362
  min_rsync_size = 30 * 1024 * 1024
326
363
 
327
364
  # Upload and Execute
328
- with self.connection() as conn:
365
+ with self._deploy_session() as conn:
329
366
  # Check rsync availability while creating the directory (single round trip)
330
367
  # Only check if bundle is large enough to benefit from rsync
331
368
  if bundle_size >= min_rsync_size:
@@ -367,43 +404,67 @@ class Deploy(BaseCommand):
367
404
 
368
405
  self.output.success("Bundle uploaded successfully.")
369
406
 
407
+ self.output.info("Executing remote installation...")
408
+
370
409
  # Write .env file directly (not bundled for security)
371
410
  remote_env_path = f"{self.config.install_dir}/.env"
372
411
  remote_env_path_q = shlex.quote(remote_env_path)
373
412
  install_dir_q = shlex.quote(self.config.install_dir)
413
+ remote_env_backup_q = shlex.quote(f"{remote_env_path}.bak")
374
414
  logger.debug("Writing .env to %s", remote_env_path)
375
415
 
376
- # Use base64 encoding to safely transfer content with special chars
377
- encoded_env = base64.b64encode(resolved_env.encode()).decode()
378
- # chown may fail on first deploy if app_user doesn't exist yet
379
- # (installer creates it), so use || true to make it non-fatal
380
- write_env_cmd = (
416
+ # Backup existing .env (ensure install dir exists first)
417
+ conn.run(
381
418
  f"mkdir -p {install_dir_q} && "
382
- f"echo {shlex.quote(encoded_env)} | base64 -d > {remote_env_path_q} && "
383
- f"chmod 640 {remote_env_path_q} && "
384
- f"(chown {self.selected_host.user}:{self.config.app_user} {remote_env_path_q} || true)"
419
+ f"cp {remote_env_path_q} {remote_env_backup_q} 2>/dev/null || true",
420
+ hide=True,
385
421
  )
386
422
 
387
423
  if self.restart_on_env_change:
388
- # Wrap command to capture old hash before writing and output it after
389
424
  new_env_hash = hashlib.sha256(resolved_env.encode()).hexdigest()
390
- cmd = (
391
- f"OLD=$(sha256sum {remote_env_path_q} 2>/dev/null | cut -d' ' -f1 || true) && "
392
- f"{write_env_cmd} && "
393
- f'echo ":::ENV_HASH:::$OLD:::ENV_HASH:::"'
425
+ old_hash_output, _ = conn.run(
426
+ f"sha256sum {remote_env_path_q} 2>/dev/null | cut -d' ' -f1 || true",
427
+ hide=True,
394
428
  )
395
- output, _ = conn.run(cmd, hide=True)
396
- # Extract hash from between markers
397
- old_env_hash = output.split(":::ENV_HASH:::")[1]
429
+ old_env_hash = old_hash_output.strip()
398
430
  if old_env_hash and old_env_hash != new_env_hash:
399
431
  self.output.info(
400
432
  "Environment variables changed, forcing full restart..."
401
433
  )
402
434
  self.full_restart = True
403
- else:
404
- conn.run(write_env_cmd, hide=True)
405
435
 
406
- self.output.info("Executing remote installation...")
436
+ # Upload .env via SCP (avoids shell escaping issues with base64 piping)
437
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".env") as tmp_env:
438
+ tmp_env.write(resolved_env)
439
+ tmp_env.flush()
440
+ conn.put(tmp_env.name, remote_env_path_q)
441
+
442
+ # chown may fail on first deploy if app_user doesn't exist yet
443
+ # (installer creates it), so use || true to make it non-fatal
444
+ conn.run(
445
+ f"chmod 640 {remote_env_path_q} && "
446
+ f"(chown {self.selected_host.user}:{self.config.app_user} {remote_env_path_q} || true)",
447
+ hide=True,
448
+ )
449
+
450
+ # Run pre-install hooks (as deploy user, with .env sourced)
451
+ pre_install_commands = resolved_hooks.get("pre_install", [])
452
+ if pre_install_commands:
453
+ self.output.info("Running pre-install hooks...")
454
+ try:
455
+ for cmd in pre_install_commands:
456
+ self.output.info(f" [pre-install] {cmd}")
457
+ full_cmd = (
458
+ f"set -a; source {install_dir_q}/.env 2>/dev/null; set +a; "
459
+ f"{cmd}"
460
+ )
461
+ conn.run(full_cmd, pty=True)
462
+ except CommandError:
463
+ conn.run(
464
+ f"mv {remote_env_backup_q} {remote_env_path_q} 2>/dev/null || true",
465
+ hide=True,
466
+ )
467
+ raise
407
468
 
408
469
  rollback_ran = False
409
470
  rollback_succeeded = False
@@ -416,15 +477,24 @@ class Deploy(BaseCommand):
416
477
  conn.run(install_cmd, pty=True)
417
478
  except CommandError as e:
418
479
  if e.code != installer.EXIT_SERVICE_START_FAILED:
480
+ conn.run(f"rm -f {remote_env_backup_q}", warn=True, hide=True)
419
481
  raise DeploymentError(
420
482
  f"Installation failed with exit code {e.code}"
421
483
  ) from e
422
484
 
423
485
  if self.no_rollback:
486
+ conn.run(f"rm -f {remote_env_backup_q}", warn=True, hide=True)
424
487
  raise DeploymentError(
425
488
  "Services failed to start. Rollback disabled via --no-rollback."
426
489
  ) from e
427
490
 
491
+ # Restore previous .env before rollback
492
+ self.output.info("Restoring previous environment...")
493
+ conn.run(
494
+ f"mv {remote_env_backup_q} {remote_env_path_q} 2>/dev/null || true",
495
+ hide=True,
496
+ )
497
+
428
498
  rollback = Rollback(host=self.host, previous=True, strict=True)
429
499
  self.output.info(
430
500
  "Services failed to start. Rolling back to previous version."
@@ -458,6 +528,9 @@ class Deploy(BaseCommand):
458
528
  warn=True,
459
529
  )
460
530
 
531
+ if not rollback_ran:
532
+ conn.run(f"rm -f {remote_env_backup_q}", warn=True, hide=True)
533
+
461
534
  # Get git commit hash if available
462
535
  log_operation(
463
536
  connection=conn,
@@ -497,6 +570,34 @@ class Deploy(BaseCommand):
497
570
  with tempfile.TemporaryDirectory() as tmpdir:
498
571
  yield Path(tmpdir)
499
572
 
573
+ @contextmanager
574
+ def _deploy_session(self) -> Generator[SSH2Connection, None, None]:
575
+ """Context manager that combines SSH connection + deployment lock.
576
+
577
+ Acquires the deploy lock on the remote server before yielding the
578
+ connection, and releases it on exit (even on error).
579
+ """
580
+ with self.connection() as conn:
581
+ install_dir_q = shlex.quote(self.config.install_dir)
582
+ lock_file_q = shlex.quote(f"{self.config.install_dir}/.deploy_lock")
583
+ conn.run(f"mkdir -p {install_dir_q}", hide=True)
584
+ _, acquired = conn.run(
585
+ f"(set -C; echo $$ > {lock_file_q}) 2>/dev/null",
586
+ warn=True,
587
+ hide=True,
588
+ )
589
+ if not acquired:
590
+ raise DeploymentError(
591
+ "Another deployment is in progress.\n"
592
+ "If you are sure no deploy is running, remove the lock manually:\n"
593
+ f" ssh {self.selected_host.user}@{self.selected_host.address} rm {lock_file_q}"
594
+ )
595
+
596
+ try:
597
+ yield conn
598
+ finally:
599
+ conn.run(f"rm -f {lock_file_q}", warn=True, hide=True)
600
+
500
601
  def _show_deployment_summary(self, bundle_size: int, bundle_version: str):
501
602
  console = Console()
502
603
 
@@ -1,5 +1,5 @@
1
1
  from __future__ import annotations
2
- from functools import cache
2
+ from functools import cache, cached_property
3
3
 
4
4
  import os
5
5
  import subprocess
@@ -45,13 +45,20 @@ class SecretConfig(msgspec.Struct):
45
45
  )
46
46
 
47
47
 
48
- class Config(msgspec.Struct, kw_only=True):
48
+ class HooksConfig(msgspec.Struct):
49
+ pre_install: list[str] = msgspec.field(default_factory=list)
50
+ post_install: list[str] = msgspec.field(default_factory=list)
51
+ post_start: list[str] = msgspec.field(default_factory=list)
52
+
53
+
54
+ class Config(msgspec.Struct, kw_only=True, dict=True):
49
55
  app_name: str = msgspec.field(name="app")
50
56
  app_user: str | None = None # User to run the app as (defaults to app_name)
51
57
  version: str = msgspec.field(default_factory=lambda: read_version_from_pyproject())
52
58
  versions_to_keep: int | None = 5
53
59
  python_version: str | None = None
54
60
  build_command: str
61
+ build_timeout: int = 300
55
62
  installation_mode: InstallationMode
56
63
  distfile: str
57
64
  aliases: dict[str, str] = msgspec.field(default_factory=dict)
@@ -70,6 +77,11 @@ class Config(msgspec.Struct, kw_only=True):
70
77
  default_factory=lambda: SecretConfig(adapter="system"),
71
78
  )
72
79
 
80
+ hooks_config: HooksConfig | None = msgspec.field(
81
+ name="hooks",
82
+ default=None,
83
+ )
84
+
73
85
  def __post_init__(self):
74
86
  if not self.app_user:
75
87
  self.app_user = self.app_name
@@ -187,7 +199,7 @@ class Config(msgspec.Struct, kw_only=True):
187
199
  def caddy_config_path(self) -> str:
188
200
  return f"{self.caddy_config_dir}/{self.app_name}.caddy"
189
201
 
190
- @property
202
+ @cached_property
191
203
  def deployed_units(self) -> list[DeployedUnit]:
192
204
  return discover_deployed_units(
193
205
  self.local_config_dir, self.app_name, self.replicas