fadenstack 0.4.4rc1__tar.gz → 0.4.5__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 (153) hide show
  1. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/PKG-INFO +1 -1
  2. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/pyproject.toml +1 -1
  3. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/__init__.py +1 -1
  4. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/docker-compose.yaml +7 -4
  5. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/deploy.py +7 -3
  6. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/module.py +2 -1
  7. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/rebuild.py +8 -6
  8. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/up.py +10 -1
  9. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/upgrade.py +7 -3
  10. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/backup.py +219 -35
  11. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/compose.py +57 -1
  12. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/env_gen.py +3 -2
  13. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/release_feed.py +4 -0
  14. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/conftest.py +1 -0
  15. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_backup_volumes.py +11 -3
  16. fadenstack-0.4.5/tests/test_data_dir_move.py +328 -0
  17. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_data_volumes_logs.py +4 -2
  18. fadenstack-0.4.5/tests/test_inactive_modules.py +131 -0
  19. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_private_files.py +2 -2
  20. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_rebuild.py +6 -4
  21. fadenstack-0.4.5/tests/test_release_versions.py +36 -0
  22. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_shared_compose_ports.py +2 -1
  23. fadenstack-0.4.5/tests/test_trusted_keys.py +32 -0
  24. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_update_feed.py +16 -0
  25. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/.gitignore +0 -0
  26. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/LICENSE +0 -0
  27. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/NOTICE +0 -0
  28. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/README.md +0 -0
  29. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/hatch_build.py +0 -0
  30. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/__main__.py +0 -0
  31. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/.gitignore +0 -0
  32. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/LICENSE +0 -0
  33. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/NOTICE +0 -0
  34. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/README.md +0 -0
  35. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/agent-binaries/.gitignore +0 -0
  36. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/alloy/config.alloy +0 -0
  37. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/clickhouse/config.d/system-logs.xml +0 -0
  38. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/docker-compose.gpu.yaml +0 -0
  39. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/grafana/dashboards/templates/vllm-runtime.json +0 -0
  40. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/grafana/provisioning/dashboards/dashboard.yml +0 -0
  41. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/grafana/provisioning/dashboards/json/faden-overview.json +0 -0
  42. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/grafana/provisioning/datasources/ds.yaml +0 -0
  43. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/grafana/provisioning/datasources/prometheus.yaml +0 -0
  44. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/initdb/01-init.sql +0 -0
  45. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/loki/local-config.yaml +0 -0
  46. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/nginx/locations.conf +0 -0
  47. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/nginx/maintenance.html +0 -0
  48. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/nginx/nginx-tls.conf +0 -0
  49. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/nginx/nginx.conf +0 -0
  50. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/nginx/rate-limits.conf +0 -0
  51. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/nginx/security-headers.conf +0 -0
  52. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/nginx/select-config.sh +0 -0
  53. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/prometheus/prometheus.yml +0 -0
  54. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/rabbitmq/definitions.json.example +0 -0
  55. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/tls/README.md +0 -0
  56. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/cli.py +0 -0
  57. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/__init__.py +0 -0
  58. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/admin.py +0 -0
  59. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/backup.py +0 -0
  60. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/config.py +0 -0
  61. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/dev/__init__.py +0 -0
  62. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/dev/dev_doctor.py +0 -0
  63. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/dev/dev_down.py +0 -0
  64. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/dev/dev_group.py +0 -0
  65. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/dev/dev_init.py +0 -0
  66. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/dev/dev_pull.py +0 -0
  67. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/dev/dev_status.py +0 -0
  68. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/dev/dev_up.py +0 -0
  69. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/doctor.py +0 -0
  70. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/down.py +0 -0
  71. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/logs_cmd.py +0 -0
  72. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/maintenance.py +0 -0
  73. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/observe.py +0 -0
  74. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/ports.py +0 -0
  75. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/restore.py +0 -0
  76. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/runtime_images.py +0 -0
  77. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/secrets.py +0 -0
  78. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/status.py +0 -0
  79. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/tls.py +0 -0
  80. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/tune.py +0 -0
  81. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/version.py +0 -0
  82. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/__init__.py +0 -0
  83. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/api_client.py +0 -0
  84. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/bootstrap.py +0 -0
  85. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/bundle.py +0 -0
  86. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/clickhouse.py +0 -0
  87. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/console.py +0 -0
  88. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/detect.py +0 -0
  89. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/dev_modules.py +0 -0
  90. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/docker_env.py +0 -0
  91. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/edge.py +0 -0
  92. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/git.py +0 -0
  93. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/install.py +0 -0
  94. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/installation.py +0 -0
  95. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/local_node.py +0 -0
  96. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/maintenance.py +0 -0
  97. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/private_files.py +0 -0
  98. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/registry.py +0 -0
  99. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/rmq.py +0 -0
  100. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/runtime_images.py +0 -0
  101. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/schedule.py +0 -0
  102. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/secret_rotation.py +0 -0
  103. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/settings.py +0 -0
  104. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/sysinfo.py +0 -0
  105. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/tls.py +0 -0
  106. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/tune.py +0 -0
  107. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/update_check.py +0 -0
  108. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/workspace.py +0 -0
  109. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/templates/env.j2 +0 -0
  110. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/tui/__init__.py +0 -0
  111. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/tui/widgets/__init__.py +0 -0
  112. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/tui/wizard/__init__.py +0 -0
  113. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_backup_retention.py +0 -0
  114. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_backup_schedule.py +0 -0
  115. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_backup_tls.py +0 -0
  116. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_binary_notices.py +0 -0
  117. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_bootstrap_token.py +0 -0
  118. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_cli_output_encoding.py +0 -0
  119. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_deploy_summary.py +0 -0
  120. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_dev_env_loopback.py +0 -0
  121. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_dev_modules.py +0 -0
  122. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_dev_up_port_holders.py +0 -0
  123. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_dev_up_reclaim.py +0 -0
  124. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_dev_up_service_hosts.py +0 -0
  125. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_dev_up_terminal.py +0 -0
  126. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_docker_env.py +0 -0
  127. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_doctor_ports.py +0 -0
  128. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_edge.py +0 -0
  129. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_generated_env_reaches_compose.py +0 -0
  130. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_installation.py +0 -0
  131. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_internal_token_env.py +0 -0
  132. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_local_node.py +0 -0
  133. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_main.py +0 -0
  134. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_maintenance.py +0 -0
  135. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_module_env.py +0 -0
  136. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_postgres_logins.py +0 -0
  137. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_redis_limits.py +0 -0
  138. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_registry_gateway_env.py +0 -0
  139. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_release_bundle.py +0 -0
  140. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_release_feed.py +0 -0
  141. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_required_env.py +0 -0
  142. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_restore_maintenance.py +0 -0
  143. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_runtime_images.py +0 -0
  144. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_secret_rotation.py +0 -0
  145. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_service_logins.py +0 -0
  146. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_tls.py +0 -0
  147. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_tls_renew.py +0 -0
  148. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_tune.py +0 -0
  149. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_tune_memory.py +0 -0
  150. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_update_check.py +0 -0
  151. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_upgrade_housekeeping.py +0 -0
  152. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_upgrade_rabbitmq.py +0 -0
  153. {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_workspace_detection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fadenstack
3
- Version: 0.4.4rc1
3
+ Version: 0.4.5
4
4
  Summary: CLI installer and management tool for the Fadenstack platform
5
5
  Project-URL: Homepage, https://fadenstack.com
6
6
  Project-URL: Repository, https://github.com/fadenstack/fadenstack
@@ -10,7 +10,7 @@ packages = ["src/faden_cli"]
10
10
 
11
11
  [project]
12
12
  name = "fadenstack"
13
- version = "0.4.4rc1"
13
+ version = "0.4.5"
14
14
  requires-python = ">=3.12,<4.0"
15
15
  description = "CLI installer and management tool for the Fadenstack platform"
16
16
  authors = [
@@ -13,4 +13,4 @@ Module activation via Docker Compose profiles.
13
13
  All secrets generated securely during init phase.
14
14
  """
15
15
 
16
- __version__ = "0.4.4rc1"
16
+ __version__ = "0.4.5"
@@ -474,7 +474,8 @@ services:
474
474
  # Chat attachments (chat_file_store_root). In the container layer until this release: every upgrade
475
475
  # and restore replaced the container and lost them. With FADEN_DATA_DIR set in .env the source is
476
476
  # that directory plus /chat-files on the host (a company's own storage); empty, the named volume
477
- # below. `faden upgrade` copies what the old container holds into it first.
477
+ # below. `faden upgrade` copies what the old container holds into it first. FADEN_DATA_DIR covers this
478
+ # and the knowledge files only; never the database, the models or the observability data.
478
479
  - ${FADEN_DATA_DIR:-faden_chat_files}${FADEN_DATA_DIR:+/chat-files}:/data/faden/chat-files
479
480
  healthcheck:
480
481
  test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=3).read()"]
@@ -619,8 +620,10 @@ services:
619
620
  - faden_models:/srv/faden/models
620
621
  # Knowledge base uploads (rag_lite.file_store_root): written here, read by faden-backend-worker,
621
622
  # which ingests them. Each container had its own: the worker found no file, every ingest failed,
622
- # and the uploads went with the container.
623
- - faden_rag_lite:/data/faden/rag-lite
623
+ # and the uploads went with the container. With FADEN_DATA_DIR set in .env the source is that
624
+ # directory plus /knowledge on the host; empty, the named volume. The CLI copies the volume's files
625
+ # into the folder before the container is created.
626
+ - ${FADEN_DATA_DIR:-faden_rag_lite}${FADEN_DATA_DIR:+/knowledge}:/data/faden/rag-lite
624
627
  # Runtime monitoring — shared with the prometheus + grafana
625
628
  # services (see their mounts). The backend generates
626
629
  # prometheus/targets.json and dashboards/*.json here; grafana
@@ -717,7 +720,7 @@ services:
717
720
  volumes:
718
721
  - /var/run/docker.sock:/var/run/docker.sock:ro
719
722
  - faden_models:/srv/faden/models
720
- - faden_rag_lite:/data/faden/rag-lite
723
+ - ${FADEN_DATA_DIR:-faden_rag_lite}${FADEN_DATA_DIR:+/knowledge}:/data/faden/rag-lite
721
724
 
722
725
  # -----------------------------
723
726
  # frontend (React admin console)
@@ -820,10 +820,11 @@ def deploy_cmd(
820
820
  # We boot Postgres alone, ALTER USER to match .env, then start
821
821
  # everything else.
822
822
  # An install from before the chat files had a volume: they go from the old gateway container into it,
823
- # verified, before anything is started or replaced (syncing the password starts Postgres).
824
- from faden_cli.core.backup import keep_chat_files # noqa: PLC0415
823
+ # verified, before anything is started or replaced (syncing the password starts Postgres). Re-running
824
+ # deploy on an install that has just set --data-dir copies the files of its named volumes into the folders.
825
+ from faden_cli.core.backup import keep_company_files # noqa: PLC0415
825
826
 
826
- keep_chat_files(env_path)
827
+ keep_company_files(env_path, ctx)
827
828
  _sync_postgres_password(ctx, env_path)
828
829
  retire_fixed_logins(env_path)
829
830
 
@@ -845,6 +846,9 @@ def deploy_cmd(
845
846
  error(f"docker compose up failed (exit code {rc}).")
846
847
  sys.exit(rc)
847
848
  success("All services started.")
849
+ from faden_cli.core.compose import report_inactive_modules # noqa: PLC0415
850
+
851
+ report_inactive_modules(ctx)
848
852
 
849
853
  # ── 6. Initial admin setup ────────────────────────────────────
850
854
  console.print("\n[bold cyan]Step 6: Initial admin setup…[/bold cyan]")
@@ -108,6 +108,7 @@ def module_disable(modules: tuple[str, ...]) -> None:
108
108
  save_config(cfg)
109
109
  _write_env(cfg, enable=[], disable=disabled)
110
110
  console.print(
111
- "\n[dim]Run [bold]faden down && faden up[/bold] to apply changes "
111
+ "\n[dim]Run [bold]faden up[/bold] to apply changes: it stops and removes the containers "
112
+ "of a disabled module and keeps its data "
112
113
  "(or [bold]faden dev up[/bold] in a dev workspace).[/dim]",
113
114
  )
@@ -130,12 +130,14 @@ def rebuild_cmd(
130
130
 
131
131
  env_path = ctx.env_file
132
132
 
133
- # The gateway's chat files: from an old container into the volume it now has, verified (see core.backup).
134
- # Before anything is recreated (syncing the password starts Postgres, which compose may recreate).
135
- if not target_services or any("gateway" in name for name in target_services):
136
- from faden_cli.core.backup import keep_chat_files # noqa: PLC0415
137
-
138
- keep_chat_files(env_path)
133
+ # The gateway's chat files: from an old container into the volume it now has, verified (see core.backup);
134
+ # and with FADEN_DATA_DIR set, the files of the volumes it covers into its folders (the gateway and the
135
+ # backend mount them). Before anything is recreated (syncing the password starts Postgres, which compose
136
+ # may recreate).
137
+ if not target_services or any("gateway" in name or "backend" in name for name in target_services):
138
+ from faden_cli.core.backup import keep_company_files # noqa: PLC0415
139
+
140
+ keep_company_files(env_path, ctx)
139
141
 
140
142
  # Step 1b: Sync Postgres password if .env credentials differ from
141
143
  # what the running Postgres volume was initialised with (e.g. after
@@ -6,7 +6,7 @@ import sys
6
6
 
7
7
  import click
8
8
 
9
- from faden_cli.core.compose import build_context_from_config, up as compose_up
9
+ from faden_cli.core.compose import build_context_from_config, report_inactive_modules, up as compose_up
10
10
  from faden_cli.core.console import console, success, error
11
11
  from faden_cli.core.env_gen import write_env_file, default_env_vars
12
12
  from faden_cli.core.settings import load_config
@@ -49,6 +49,12 @@ def up_cmd(
49
49
 
50
50
  console.print("[bold cyan]Starting Fadenstack services…[/bold cyan]")
51
51
 
52
+ # With FADEN_DATA_DIR set in .env, the files of the named volumes it covers go into its folders first,
53
+ # verified: the containers about to be created mount the (possibly empty) folders (see core.backup).
54
+ from faden_cli.core.backup import keep_company_files # noqa: PLC0415
55
+
56
+ keep_company_files(ctx.env_file, ctx)
57
+
52
58
  returncode = compose_up(
53
59
  ctx,
54
60
  detach=detach,
@@ -59,6 +65,9 @@ def up_cmd(
59
65
 
60
66
  if returncode == 0:
61
67
  success("Services started successfully.")
68
+ # Only when everything was started: naming services is a partial start.
69
+ if not services:
70
+ report_inactive_modules(ctx)
62
71
  else:
63
72
  error(f"docker compose up exited with code {returncode}.")
64
73
  sys.exit(returncode)
@@ -368,10 +368,11 @@ def _restart_and_check(ctx: ComposeContext, shared_dir: Path, env_path: Path, st
368
368
  # Chat attachments an older release kept inside the gateway container, which the restart below replaces:
369
369
  # copied into the volume they now have and verified, while the old container is still there. First of
370
370
  # all, before anything is replaced (syncing the password starts Postgres, which compose may recreate): if
371
- # they cannot be kept, the command stops here and the old containers keep running.
372
- from faden_cli.core.backup import keep_chat_files # noqa: PLC0415
371
+ # they cannot be kept, the command stops here and the old containers keep running. With FADEN_DATA_DIR set,
372
+ # the files of the named volumes it covers are copied into its folders the same way.
373
+ from faden_cli.core.backup import keep_company_files # noqa: PLC0415
373
374
 
374
- keep_chat_files(env_path)
375
+ keep_company_files(env_path, ctx)
375
376
 
376
377
  # Sync postgres password before restarting (same as deploy)
377
378
  from faden_cli.commands.deploy import _sync_postgres_password, retire_fixed_logins # noqa: PLC0415
@@ -404,6 +405,9 @@ def _restart_and_check(ctx: ComposeContext, shared_dir: Path, env_path: Path, st
404
405
  error(f"docker compose up failed (exit code {rc}).")
405
406
  sys.exit(rc)
406
407
  success("All services restarted.")
408
+ from faden_cli.core.compose import report_inactive_modules # noqa: PLC0415
409
+
410
+ report_inactive_modules(ctx)
407
411
 
408
412
  # ClickHouse's diagnostic logs are off from this release; what they hold
409
413
  # stays until dropped (41 GB of trace_log filled one install's disk).
@@ -44,7 +44,12 @@ BACKUP_VOLUMES: list[str] = [
44
44
 
45
45
  #: The company data volumes docker-compose.yaml can put in a host directory instead: with FADEN_DATA_DIR set in
46
46
  #: .env, the volume is that directory plus the subdirectory named here. Mirrors the mounts in the compose file.
47
- DATA_DIR_VOLUMES: dict[str, str] = {"faden_chat_files": "chat-files"}
47
+ #: This is the whole scope, settled before the first release that ships it: chat attachments and the knowledge
48
+ #: base's files. Never in it, so that setting FADEN_DATA_DIR can never put a volume on an empty folder in a
49
+ #: later release: the database (pg_data), the model store (faden_models) and the observability data. They get
50
+ #: settings of their own. Adding an entry here later would make every install that set FADEN_DATA_DIR mount an
51
+ #: empty folder, and its data would look lost.
52
+ DATA_DIR_VOLUMES: dict[str, str] = {"faden_chat_files": "chat-files", "faden_rag_lite": "knowledge"}
48
53
 
49
54
  #: Where the gateway keeps chat attachments, and its container: files an older release left in the
50
55
  #: container layer are carried into the volume before the container is replaced.
@@ -373,12 +378,17 @@ def _volume_source(docker: str, project: str, name: str, data_dir: str) -> str |
373
378
  return _compose_volume(docker, project, name)
374
379
 
375
380
 
376
- class ChatFilesNotCarried(RuntimeError):
377
- """The chat attachments in the old gateway container could not be copied and verified.
381
+ class FilesNotCarried(RuntimeError):
382
+ """Company files could not be copied and verified. The message says what failed and what to do. Raised
383
+ before any container is replaced, so the files are where they were."""
384
+
385
+
386
+ class ChatFilesNotCarried(FilesNotCarried):
387
+ """The chat attachments in the old gateway container could not be copied and verified."""
378
388
 
379
- The message says what failed and what to do. Raised before any container is replaced, so the files are
380
- where they were.
381
- """
389
+
390
+ class DataDirNotMoved(FilesNotCarried):
391
+ """The files of a named volume could not be copied into the FADEN_DATA_DIR folder and verified."""
382
392
 
383
393
 
384
394
  @dataclass
@@ -420,29 +430,37 @@ _HASH_DIRECTORY = (
420
430
  _COPY_STREAM = "tar -x --skip-old-files -f - -C /dst"
421
431
 
422
432
 
423
- def _parse_hashes(output: str) -> dict[str, tuple[str, int]]:
433
+ def _parse_hashes(output: str, failure: type[FilesNotCarried] = ChatFilesNotCarried) -> dict[str, tuple[str, int]]:
424
434
  """Path (relative) → (SHA-256, size) from the lines :data:`_HASH_MEMBER` prints."""
425
435
  found: dict[str, tuple[str, int]] = {}
426
436
  for line in output.splitlines():
427
437
  parts = line.split("\t", 2)
428
438
  if len(parts) != 3 or len(parts[0]) != 64 or not parts[1].isdigit():
429
- raise ChatFilesNotCarried(f"The helper container answered something unexpected: {line[:120]!r}.")
439
+ raise failure(f"The helper container answered something unexpected: {line[:120]!r}.")
430
440
  found[parts[2].removeprefix("./")] = (parts[0], int(parts[1]))
431
441
  return found
432
442
 
433
443
 
434
- def _helper(docker: str, image: str, script: str, *, mount: str | None = None, interactive: bool = False) -> list[str]:
435
- """``docker run`` of the old gateway's own image, running *script* as root with *mount* on ``/dst``.
444
+ def _helper(
445
+ docker: str,
446
+ image: str,
447
+ script: str,
448
+ *,
449
+ mount: str | None = None,
450
+ interactive: bool = False,
451
+ extra_mounts: tuple[str, ...] = (),
452
+ ) -> list[str]:
453
+ """``docker run`` of an image already on the server, running *script* as root with *mount* on ``/dst``.
436
454
 
437
- The image is on the server already (an air-gapped one pulls nothing) and is Debian with GNU tar and
438
- sha256sum. Root, to read and write whatever user the files belong to.
455
+ The image is the old gateway's or the backend's own (an air-gapped server pulls nothing) and is Debian
456
+ with GNU tar and sha256sum. Root, to read and write whatever user the files belong to.
439
457
  """
440
458
  cmd = [docker, "run", "--rm"]
441
459
  if interactive:
442
460
  cmd.append("-i")
443
461
  cmd += ["--user", "0:0", "--entrypoint", "sh"]
444
- if mount is not None:
445
- cmd += ["-v", mount]
462
+ for each in (*([mount] if mount is not None else []), *extra_mounts):
463
+ cmd += ["-v", each]
446
464
  return [*cmd, image, "-c", script]
447
465
 
448
466
 
@@ -575,10 +593,9 @@ def carry_over_chat_files(data_dir: str = "") -> CarryResult:
575
593
 
576
594
  info("Reading the chat attachments in the old gateway container…")
577
595
  listing = _stream_from_container(docker, image, _HASH_STREAM)
578
- if listing is None:
579
- return CarryResult("none") # the old container has no such directory: nothing to carry
580
- source = _parse_hashes(listing)
581
- if not source:
596
+ if listing is None or not (source := _parse_hashes(listing)):
597
+ # No such directory, or an empty one: said, so the line above does not end in silence.
598
+ info(" No chat attachments there: nothing to carry over.")
582
599
  return CarryResult("none")
583
600
  size = sum(n for _, n in source.values())
584
601
  info(f" {len(source)} file(s), {_human_size(size)}; copying into the data volume…")
@@ -615,10 +632,178 @@ def carry_over_chat_files(data_dir: str = "") -> CarryResult:
615
632
  return CarryResult("copied", len(source), size)
616
633
 
617
634
 
618
- def snapshot_volumes(backup_dir: Path, *, volumes: list[str] | None = None, data_dir: str = "") -> list[str]:
635
+ #: The same lines for the files of a named volume, mounted read-only on /src.
636
+ _HASH_VOLUME = _HASH_DIRECTORY.replace("/dst", "/src")
637
+ #: Volume -> folder without ever replacing a file the folder holds. No ``pipefail`` in dash: a failed read side
638
+ #: shows up as files missing in the check after the copy.
639
+ _MOVE_VOLUME = "tar -c -f - -C /src . | tar -x --skip-old-files -f - -C /dst"
640
+
641
+ _VOLUME_LEFT = (
642
+ "Nothing was replaced: the containers still run and the named volume is untouched. Fix the cause named, "
643
+ "then run the command again."
644
+ )
645
+
646
+
647
+ @dataclass
648
+ class MoveResult:
649
+ """What :func:`move_into_data_dir` did for one volume: ``"moved"``, ``"present"`` (the folder already held
650
+ every file) or ``"none"`` (no such volume, or it is empty), and how many files and bytes were copied."""
651
+
652
+ volume: str
653
+ status: str
654
+ folder: str = ""
655
+ files: int = 0
656
+ size: int = 0
657
+
658
+
659
+ def _helper_image(docker: str, ctx: object | None = None) -> str:
660
+ """An image already on the server for the helper container: the running gateway's or backend's own,
661
+ else the backend image the compose file names (an air-gapped server pulls nothing)."""
662
+ for container in (_GATEWAY_CONTAINER, "faden-backend"):
663
+ proc = subprocess.run( # noqa: S603
664
+ [docker, "inspect", "-f", "{{.Image}}", container], capture_output=True, check=False
665
+ )
666
+ image = proc.stdout.decode("utf-8", errors="replace").strip() if proc.returncode == 0 else ""
667
+ if image:
668
+ return image
669
+ if ctx is not None:
670
+ proc = subprocess.run( # noqa: S603
671
+ [*ctx.base_cmd(), "config", "--images"], capture_output=True, text=True, check=False # type: ignore[attr-defined]
672
+ )
673
+ for line in (proc.stdout or "").splitlines():
674
+ if proc.returncode == 0 and "/backend:" in line.strip():
675
+ return line.strip()
676
+ raise DataDirNotMoved(
677
+ f"No image to copy the files with: neither {_GATEWAY_CONTAINER} nor faden-backend exists and the compose "
678
+ f"file names no backend image. {_VOLUME_LEFT}"
679
+ )
680
+
681
+
682
+ def _hash_tree(docker: str, image: str, path: str, *, role: str) -> dict[str, tuple[str, int]]:
683
+ """Path → (SHA-256, size) of every file under *path* (a volume, as ``/src``, or a folder, as ``/dst``)."""
684
+ script, where = (_HASH_VOLUME, "src") if role == "volume" else (_HASH_DIRECTORY, "dst")
685
+ proc = subprocess.run( # noqa: S603
686
+ _helper(docker, image, script, mount=f"{path}:/{where}:ro"), capture_output=True, check=False
687
+ )
688
+ if proc.returncode != 0:
689
+ detail = proc.stderr.decode("utf-8", errors="replace").strip()[:300]
690
+ raise DataDirNotMoved(
691
+ f"The files in the {role} {path} could not be read to check them "
692
+ f"({detail or 'the helper failed'}). {_VOLUME_LEFT}"
693
+ )
694
+ return _parse_hashes(proc.stdout.decode("utf-8", errors="replace"), DataDirNotMoved)
695
+
696
+
697
+ def _move_volume(docker: str, image: str, name: str, volume: str, folder: str) -> MoveResult:
698
+ """Copy what the named *volume* holds and *folder* lacks into *folder*, then check every file's SHA-256."""
699
+ source = _hash_tree(docker, image, volume, role="volume")
700
+ if not source:
701
+ return MoveResult(name, "none", folder)
702
+ found = _hash_tree(docker, image, folder, role="folder")
703
+ differing = [path for path, (digest, _) in source.items() if path in found and found[path][0] != digest]
704
+ if differing:
705
+ raise DataDirNotMoved(
706
+ f"{len(differing)} file(s) of {name} exist in {folder} with other content than in the volume: "
707
+ f"{_named(differing)}. Nothing was overwritten and nothing was replaced. Compare the two copies, "
708
+ f"move the one you do not want out of the way, then run the command again."
709
+ )
710
+ size = sum(n for _, n in source.values())
711
+ if all(path in found for path in source):
712
+ return MoveResult(name, "present", folder)
713
+ info(f" {name}: {len(source)} file(s), {_human_size(size)}; copying into {folder}…")
714
+ proc = subprocess.run( # noqa: S603
715
+ _helper(docker, image, _MOVE_VOLUME, mount=f"{folder}:/dst", extra_mounts=(f"{volume}:/src:ro",)),
716
+ capture_output=True,
717
+ check=False,
718
+ )
719
+ if proc.returncode != 0:
720
+ detail = proc.stderr.decode("utf-8", errors="replace").strip()[:300]
721
+ raise DataDirNotMoved(
722
+ f"The files of {name} could not be copied into {folder} ({detail or 'the helper container failed'}). "
723
+ f"{_VOLUME_LEFT}"
724
+ )
725
+ after = _hash_tree(docker, image, folder, role="folder")
726
+ differing = [p for p, (digest, _) in source.items() if p in after and after[p][0] != digest]
727
+ if differing:
728
+ raise DataDirNotMoved(
729
+ f"{len(differing)} file(s) of {name} differ in {folder} after copying: {_named(differing)}. They "
730
+ f"changed in the volume while it was copied, or were damaged on the way. {_VOLUME_LEFT}"
731
+ )
732
+ missing = [path for path in source if path not in after]
733
+ if missing:
734
+ raise DataDirNotMoved(
735
+ f"{len(missing)} file(s) of {name} are not in {folder} after copying: {_named(missing)}. {_VOLUME_LEFT}"
736
+ )
737
+ return MoveResult(name, "moved", folder, len(source), size)
738
+
739
+
740
+ def move_into_data_dir(data_dir: str, ctx: object | None = None) -> list[MoveResult]:
741
+ """With FADEN_DATA_DIR set: copy the files of every named volume in :data:`DATA_DIR_VOLUMES` that still
742
+ exists into its folder, verified, before a container that mounts the folder is created.
743
+
744
+ Switching FADEN_DATA_DIR on would otherwise put an empty folder where the volume's files are. The copy
745
+ never overwrites (``tar --skip-old-files``); then every file of the volume must be in the folder with the
746
+ same SHA-256. A file in both with other content is a conflict that stops everything before anything is
747
+ written. The volume is left as it was. Any failure raises :class:`DataDirNotMoved`; nothing is replaced.
748
+ Safe to run again: a folder that holds everything is left alone. Switching it off again copies nothing back.
749
+ """
750
+ if not data_dir:
751
+ return []
752
+ docker = _docker_bin()
753
+ project = _compose_project(docker) or _DEFAULT_PROJECT
754
+ results: list[MoveResult] = []
755
+ image = ""
756
+ for name, subdirectory in DATA_DIR_VOLUMES.items():
757
+ volume = _compose_volume(docker, project, name)
758
+ folder = f"{data_dir}/{subdirectory}"
759
+ if volume is None:
760
+ results.append(MoveResult(name, "none", folder))
761
+ continue
762
+ image = image or _helper_image(docker, ctx)
763
+ results.append(_move_volume(docker, image, name, volume, folder))
764
+ return results
765
+
766
+
767
+ def keep_company_files(env_file: object, ctx: object | None = None) -> None:
768
+ """Before containers are created with this ``.env``: :func:`keep_chat_files`, then, when FADEN_DATA_DIR is
769
+ set, :func:`move_into_data_dir`.
770
+
771
+ The chat files go first: from an old gateway container (0.4.3 and older, no mount) straight into the
772
+ folder, which creates no named volume; the move after it then finds no volume for them, or one whose files
773
+ the folder already holds. The two never write the same file differently: both skip files that exist and
774
+ stop on a conflict. When anything cannot be kept, the command stops here with the reason; nothing is replaced.
775
+ """
776
+ keep_chat_files(env_file)
777
+ try:
778
+ results = move_into_data_dir(data_dir_of(env_file), ctx)
779
+ except FilesNotCarried as exc:
780
+ error(str(exc))
781
+ sys.exit(1)
782
+ for result in results:
783
+ if result.status == "moved":
784
+ success(
785
+ f"{result.volume} copied into {result.folder} and verified ({result.files} file(s), "
786
+ f"{_human_size(result.size)}). The named volume is left as it was: once you are satisfied, "
787
+ f"remove it with `docker volume rm`."
788
+ )
789
+
790
+
791
+ def _volume_image(docker: str, ctx: object | None) -> str:
792
+ """The image a volume backup or restore runs in: one already on the server (``_helper_image``), so an
793
+ air-gapped server pulls nothing; ``alpine`` only where no Fadenstack image is there at all."""
794
+ try:
795
+ return _helper_image(docker, ctx)
796
+ except DataDirNotMoved:
797
+ return "alpine"
798
+
799
+
800
+ def snapshot_volumes(
801
+ backup_dir: Path, *, volumes: list[str] | None = None, data_dir: str = "", ctx: object | None = None
802
+ ) -> list[str]:
619
803
  """Tar each named Docker volume into the backup directory.
620
804
 
621
- Uses a temporary ``alpine`` container to read from the volume mount. The archive carries the compose
805
+ Uses a temporary container of an image already on the server (``_volume_image``) to read from the
806
+ volume mount. The archive carries the compose
622
807
  name (``minio_data.tar.gz``), so a restore finds the volume on whichever install it runs.
623
808
  Returns list of created archive filenames.
624
809
 
@@ -630,6 +815,7 @@ def snapshot_volumes(backup_dir: Path, *, volumes: list[str] | None = None, data
630
815
  project = _compose_project(docker)
631
816
  targets = volumes or list(BACKUP_VOLUMES)
632
817
  created: list[str] = []
818
+ image = ""
633
819
 
634
820
  for vol in targets:
635
821
  source = _volume_source(docker, project, vol, data_dir)
@@ -638,15 +824,11 @@ def snapshot_volumes(backup_dir: Path, *, volumes: list[str] | None = None, data
638
824
  continue
639
825
  archive = f"{vol}.tar.gz"
640
826
  archive_path = backup_dir / archive
827
+ image = image or _volume_image(docker, ctx)
641
828
  info(f" Snapshotting volume: {vol}")
642
829
  with open_private(archive_path) as out:
643
830
  proc = subprocess.run( # noqa: S603
644
- [
645
- docker, "run", "--rm",
646
- "-v", f"{source}:/data:ro",
647
- "alpine",
648
- "tar", "czf", "-", "-C", "/data", ".",
649
- ],
831
+ _helper(docker, image, "tar czf - -C /data .", mount=f"{source}:/data:ro"),
650
832
  stdout=out,
651
833
  stderr=subprocess.PIPE,
652
834
  check=False,
@@ -661,7 +843,9 @@ def snapshot_volumes(backup_dir: Path, *, volumes: list[str] | None = None, data
661
843
  return created
662
844
 
663
845
 
664
- def restore_volumes(backup_dir: Path, manifest: dict, *, data_dir: str = "") -> list[str]:
846
+ def restore_volumes(
847
+ backup_dir: Path, manifest: dict, *, data_dir: str = "", ctx: object | None = None
848
+ ) -> list[str]:
665
849
  """Restore volume archives from a backup.
666
850
 
667
851
  Returns list of volume names that were restored.
@@ -670,6 +854,7 @@ def restore_volumes(backup_dir: Path, manifest: dict, *, data_dir: str = "") ->
670
854
  project = _compose_project(docker)
671
855
  archives = manifest.get("volumes", [])
672
856
  restored: list[str] = []
857
+ image = ""
673
858
 
674
859
  for archive in archives:
675
860
  archive_path = backup_dir / archive
@@ -693,17 +878,16 @@ def restore_volumes(backup_dir: Path, manifest: dict, *, data_dir: str = "") ->
693
878
  if target is None:
694
879
  warning(f" No volume {vol} in this install; {archive} not restored")
695
880
  continue
881
+ image = image or _volume_image(docker, ctx)
696
882
  info(f" Restoring volume: {vol}")
697
883
  # The archive goes in on the container's input: the backup is its owner's only, which a container's
698
884
  # root may not be (user namespaces), and a mount of it is not needed.
699
885
  with archive_path.open("rb") as archive_in:
700
886
  proc = subprocess.run( # noqa: S603
701
- [
702
- docker, "run", "--rm", "-i",
703
- "-v", f"{target}:/data",
704
- "alpine",
705
- "sh", "-c", "find /data -mindepth 1 -delete && tar xzf - -C /data",
706
- ],
887
+ _helper(
888
+ docker, image, "find /data -mindepth 1 -delete && tar xzf - -C /data",
889
+ mount=f"{target}:/data", interactive=True,
890
+ ),
707
891
  stdin=archive_in,
708
892
  capture_output=True,
709
893
  check=False,
@@ -983,7 +1167,7 @@ def create_backup(
983
1167
  # 4. Snapshot volumes
984
1168
  if include_volumes and not db_only:
985
1169
  console.print("\n[bold cyan]Snapshotting Docker volumes…[/bold cyan]")
986
- result.volumes = snapshot_volumes(backup_dir, data_dir=data_dir_of(ctx.env_file))
1170
+ result.volumes = snapshot_volumes(backup_dir, data_dir=data_dir_of(ctx.env_file), ctx=ctx)
987
1171
 
988
1172
  # 5. Collect migration heads
989
1173
  console.print("\n[bold cyan]Collecting migration heads…[/bold cyan]")
@@ -1055,7 +1239,7 @@ def restore_backup(
1055
1239
  # 4. Restore volumes
1056
1240
  if not db_only and manifest.get("volumes"):
1057
1241
  console.print("\n[bold cyan]Restoring Docker volumes…[/bold cyan]")
1058
- result.volumes_restored = restore_volumes(backup_dir, manifest, data_dir=data_dir_of(ctx.env_file))
1242
+ result.volumes_restored = restore_volumes(backup_dir, manifest, data_dir=data_dir_of(ctx.env_file), ctx=ctx)
1059
1243
 
1060
1244
  # 5. The certificate material, so the machines still trust this server
1061
1245
  if not db_only and manifest.get("tls_material") and ctx.project_dir:
@@ -19,7 +19,7 @@ import sys
19
19
  from dataclasses import dataclass, field
20
20
  from pathlib import Path
21
21
 
22
- from faden_cli.core.console import console, error
22
+ from faden_cli.core.console import console, error, info, warning
23
23
 
24
24
 
25
25
  # ── GPU detection ─────────────────────────────────────────────────
@@ -242,6 +242,62 @@ def service_names(ctx: ComposeContext) -> list[str]:
242
242
  return [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
243
243
 
244
244
 
245
+ def stop_inactive_modules(ctx: ComposeContext) -> list[tuple[str, list[str]]]:
246
+ """Stop and remove the containers of modules that are switched off.
247
+
248
+ ``compose up`` only starts the services of active profiles; it never stops
249
+ those of a profile that was switched off, so ``module disable`` left them
250
+ running. Only services whose profiles are all module profiles and none of
251
+ them active are touched (core services have no profile). Volumes stay.
252
+ Returns ``(module, container names)`` for each module it cleaned up.
253
+ """
254
+ from dataclasses import replace # noqa: PLC0415
255
+
256
+ from faden_cli.core.registry import MODULES # noqa: PLC0415
257
+
258
+ # "*" makes compose see every profile, so the services of inactive ones are listed too.
259
+ everything = replace(ctx, profiles=["*"])
260
+ config = _run(everything.base_cmd() + ["config", "--format", "json"], capture=True)
261
+ if config.returncode != 0:
262
+ return []
263
+ try:
264
+ spec = json.loads(config.stdout or "{}")
265
+ except ValueError:
266
+ return []
267
+ by_profile = {m.profile: name for name, m in MODULES.items()}
268
+ active = set(ctx.profiles)
269
+ owner: dict[str, str] = {}
270
+ for service, body in (spec.get("services") or {}).items():
271
+ profiles = set(body.get("profiles") or []) if isinstance(body, dict) else set()
272
+ if profiles and profiles <= set(by_profile) and not profiles & active:
273
+ owner[service] = by_profile[sorted(profiles)[0]]
274
+ if not owner:
275
+ return []
276
+ present = {row.service: row.name for row in ps(everything) if row.service in owner}
277
+ if not present:
278
+ return []
279
+ names = sorted(present)
280
+ failed = stop(everything, services=names)
281
+ failed = failed or _run(everything.base_cmd() + ["rm", "-f", *names], stream=True).returncode
282
+ if failed:
283
+ # Not fatal for the command that called it: the active services run. Said, so it is not missed.
284
+ containers = ", ".join(sorted(present.values()))
285
+ warning(f"The containers of switched-off modules could not be stopped and removed: {containers}. "
286
+ f"Run `faden up` again, or remove them with `docker rm -f {' '.join(sorted(present.values()))}`.")
287
+ return []
288
+ cleaned: dict[str, list[str]] = {}
289
+ for service, container in sorted(present.items()):
290
+ cleaned.setdefault(owner[service], []).append(container or service)
291
+ return sorted(cleaned.items())
292
+
293
+
294
+ def report_inactive_modules(ctx: ComposeContext) -> None:
295
+ """Run :func:`stop_inactive_modules` and print one line per module it stopped."""
296
+ for module, containers in stop_inactive_modules(ctx):
297
+ label = module.upper() if len(module) <= 3 else module.capitalize()
298
+ info(f"{label} is switched off: stopped and removed {', '.join(containers)} (their data is kept).")
299
+
300
+
245
301
  def down(
246
302
  ctx: ComposeContext,
247
303
  *,
@@ -69,8 +69,9 @@ SECRET_KEYS: frozenset[str] = frozenset({
69
69
  #: fallbacks). Rotation of every container's Docker log: the largest file and how many are kept per container,
70
70
  #: so 50m x 5 is at most 250 MB each. FADEN_REDIS_MAXMEMORY: the most memory Redis uses; at it Redis refuses
71
71
  #: writes (noeviction) rather than the host running out (see the redis service in the compose file).
72
- #: FADEN_DATA_DIR: empty keeps the company data (chat attachments) in
73
- #: Docker named volumes; a directory puts it there instead (Help: Installing, Where the data is kept).
72
+ #: FADEN_DATA_DIR: empty keeps the company data (chat attachments and knowledge files, nothing else: see
73
+ #: ``DATA_DIR_VOLUMES``) in Docker named volumes; a directory puts it there instead (Help: Installing, Where the
74
+ #: data is kept).
74
75
  DEPLOY_SETTINGS: dict[str, str] = {
75
76
  "FADEN_LOG_MAX_SIZE": "50m",
76
77
  "FADEN_LOG_MAX_FILE": "5",
@@ -33,6 +33,10 @@ FEED_URL = "https://fadenstack.com/updates/v1/index.json"
33
33
  #: ships here in a release before anything is signed with it.
34
34
  TRUSTED_KEYS: dict[str, str] = {
35
35
  "fadenstack-2026-10": "F87bn6AH1s5Lrw0bUKebHzxWO23vv8A6Hh9/+cxHjyo=",
36
+ # The backup key: its private half is kept offline and signs nothing while the key above is usable. A key
37
+ # an install trusts can only come with a release, so without it a lost or leaked signing key would leave
38
+ # every install unable to trust a feed again until someone upgraded it by hand.
39
+ "fadenstack-backup-2026-10": "4X1SvMFK7tO+04Ua+y0w7TC/GaW43tlqzr3vUoxFVWU=",
36
40
  }
37
41
 
38
42
  #: Least to most urgent. "none": nothing newer.
@@ -27,6 +27,7 @@ from faden_cli.core import backup
27
27
  def never_touch_real_processes(monkeypatch: pytest.MonkeyPatch) -> None:
28
28
  # The upgrade, deploy and rebuild steps that carry chat files out of the gateway container: none here.
29
29
  monkeypatch.setattr(backup, "keep_chat_files", lambda *_a, **_k: None)
30
+ monkeypatch.setattr(backup, "keep_company_files", lambda *_a, **_k: None)
30
31
 
31
32
  # Nothing to find...
32
33
  monkeypatch.setattr(dev_up.psutil, "process_iter", lambda *_a, **_k: [])