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.
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/PKG-INFO +1 -1
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/pyproject.toml +1 -1
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/__init__.py +1 -1
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/docker-compose.yaml +7 -4
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/deploy.py +7 -3
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/module.py +2 -1
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/rebuild.py +8 -6
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/up.py +10 -1
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/upgrade.py +7 -3
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/backup.py +219 -35
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/compose.py +57 -1
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/env_gen.py +3 -2
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/release_feed.py +4 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/conftest.py +1 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_backup_volumes.py +11 -3
- fadenstack-0.4.5/tests/test_data_dir_move.py +328 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_data_volumes_logs.py +4 -2
- fadenstack-0.4.5/tests/test_inactive_modules.py +131 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_private_files.py +2 -2
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_rebuild.py +6 -4
- fadenstack-0.4.5/tests/test_release_versions.py +36 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_shared_compose_ports.py +2 -1
- fadenstack-0.4.5/tests/test_trusted_keys.py +32 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_update_feed.py +16 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/.gitignore +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/LICENSE +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/NOTICE +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/README.md +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/hatch_build.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/__main__.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/.gitignore +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/LICENSE +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/NOTICE +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/README.md +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/agent-binaries/.gitignore +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/alloy/config.alloy +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/clickhouse/config.d/system-logs.xml +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/docker-compose.gpu.yaml +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/grafana/dashboards/templates/vllm-runtime.json +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/grafana/provisioning/dashboards/dashboard.yml +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/grafana/provisioning/dashboards/json/faden-overview.json +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/grafana/provisioning/datasources/ds.yaml +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/grafana/provisioning/datasources/prometheus.yaml +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/initdb/01-init.sql +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/loki/local-config.yaml +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/nginx/locations.conf +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/nginx/maintenance.html +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/nginx/nginx-tls.conf +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/nginx/nginx.conf +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/nginx/rate-limits.conf +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/nginx/security-headers.conf +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/nginx/select-config.sh +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/prometheus/prometheus.yml +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/rabbitmq/definitions.json.example +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/bundle/shared/tls/README.md +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/cli.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/__init__.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/admin.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/backup.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/config.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/dev/__init__.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/dev/dev_doctor.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/dev/dev_down.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/dev/dev_group.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/dev/dev_init.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/dev/dev_pull.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/dev/dev_status.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/dev/dev_up.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/doctor.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/down.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/logs_cmd.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/maintenance.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/observe.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/ports.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/restore.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/runtime_images.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/secrets.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/status.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/tls.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/tune.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/commands/version.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/__init__.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/api_client.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/bootstrap.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/bundle.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/clickhouse.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/console.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/detect.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/dev_modules.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/docker_env.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/edge.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/git.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/install.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/installation.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/local_node.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/maintenance.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/private_files.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/registry.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/rmq.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/runtime_images.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/schedule.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/secret_rotation.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/settings.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/sysinfo.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/tls.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/tune.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/update_check.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/core/workspace.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/templates/env.j2 +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/tui/__init__.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/tui/widgets/__init__.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/src/faden_cli/tui/wizard/__init__.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_backup_retention.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_backup_schedule.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_backup_tls.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_binary_notices.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_bootstrap_token.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_cli_output_encoding.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_deploy_summary.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_dev_env_loopback.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_dev_modules.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_dev_up_port_holders.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_dev_up_reclaim.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_dev_up_service_hosts.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_dev_up_terminal.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_docker_env.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_doctor_ports.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_edge.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_generated_env_reaches_compose.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_installation.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_internal_token_env.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_local_node.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_main.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_maintenance.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_module_env.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_postgres_logins.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_redis_limits.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_registry_gateway_env.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_release_bundle.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_release_feed.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_required_env.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_restore_maintenance.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_runtime_images.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_secret_rotation.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_service_logins.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_tls.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_tls_renew.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_tune.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_tune_memory.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_update_check.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_upgrade_housekeeping.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_upgrade_rabbitmq.py +0 -0
- {fadenstack-0.4.4rc1 → fadenstack-0.4.5}/tests/test_workspace_detection.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
377
|
-
"""
|
|
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
|
-
|
|
380
|
-
|
|
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
|
|
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(
|
|
435
|
-
|
|
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
|
|
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",
|
|
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
|
-
|
|
580
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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,
|
|
703
|
-
|
|
704
|
-
|
|
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
|
|
73
|
-
#: Docker named volumes; a directory puts it there instead (Help: Installing, Where the
|
|
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: [])
|