diff --git a/.env.example b/.env.example index 39e53a6..acf6bcb 100644 --- a/.env.example +++ b/.env.example @@ -195,6 +195,10 @@ OBSIDIAN_E2E_PASSWORD= OBSIDIAN_DEVICE_NAME=simbarag # Set to true to run continuous sync in the background OBSIDIAN_CONTINUOUS_SYNC=false +# bidirectional: notes and journal tasks Simba writes sync back to your devices. +# pull-only: download only, Simba's writes stay in the container's copy. +# mirror-remote: download only and revert local changes. +OBSIDIAN_SYNC_MODE=bidirectional OBSIDIAN_SYNC_INTERVAL=60 # Local path to Obsidian vault (where files are synced) OBSIDIAN_VAULT_PATH=/app/data/obsidian diff --git a/config/validation.py b/config/validation.py index 281c77a..94d02c4 100644 --- a/config/validation.py +++ b/config/validation.py @@ -225,6 +225,14 @@ def validate_configuration(env: Mapping[str, str], *, production: bool = True) - problem(flag, "signature validation must remain enabled in production") for name in ("METRICS_TOKEN", "CRON_SECRET", "EMAIL_HMAC_SECRET"): secret(name, minimum=32) + if env.get("OBSIDIAN_SYNC_MODE", "bidirectional") not in { + "bidirectional", + "pull-only", + "mirror-remote", + }: + problem( + "OBSIDIAN_SYNC_MODE", "must be bidirectional, pull-only or mirror-remote" + ) if env.get("OBSIDIAN_CONTINUOUS_SYNC") == "true": for name in ("OBSIDIAN_EMAIL", "OBSIDIAN_PASSWORD", "OBSIDIAN_VAULT_ID"): if not value(name): diff --git a/docker-compose.yml b/docker-compose.yml index fdca63c..0ceccac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,7 @@ x-raggr-common: &raggr-common - OBSIDIAN_E2E_PASSWORD=${OBSIDIAN_E2E_PASSWORD} - OBSIDIAN_DEVICE_NAME=${OBSIDIAN_DEVICE_NAME} - OBSIDIAN_CONTINUOUS_SYNC=${OBSIDIAN_CONTINUOUS_SYNC:-false} + - OBSIDIAN_SYNC_MODE=${OBSIDIAN_SYNC_MODE:-bidirectional} - OBSIDIAN_VAULT_PATH=${OBSIDIAN_VAULT_PATH:-/app/data/obsidian} - OBSIDIAN_VAULT_NAME=${OBSIDIAN_VAULT_NAME:-} - S3_ENDPOINT_URL=${S3_ENDPOINT_URL} diff --git a/docs/deployment.md b/docs/deployment.md index 768b51c..ef9471d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -39,7 +39,7 @@ make logs make migrate-history ``` -Startup validates configuration, runs `aerich upgrade`, optionally sets up Obsidian pull-only sync, and replaces the shell with the Python process. **A migration failure stops startup; the application is never launched.** Obsidian login/setup failures still log a warning and allow the application to continue. +Startup validates configuration, runs `aerich upgrade`, optionally starts Obsidian sync in the background (`OBSIDIAN_SYNC_MODE`, default `bidirectional` so notes and tasks Simba writes reach your devices; containers take turns via a lock file in the shared vault), and replaces the shell with the Python process. **A migration failure stops startup; the application is never launched.** Obsidian login/setup failures still log a warning and allow the application to continue. An empty database without Aerich metadata may need explicit initialization. Inspect its state first. Run the migration tool in a one-off container because the app cannot start until migrations pass: diff --git a/startup.sh b/startup.sh index c681354..86d3783 100644 --- a/startup.sh +++ b/startup.sh @@ -19,27 +19,36 @@ if [ "${OBSIDIAN_CONTINUOUS_SYNC:-false}" = "true" ]; then if [ -z "${OBSIDIAN_EMAIL:-}" ] || [ -z "${OBSIDIAN_PASSWORD:-}" ] || [ -z "${OBSIDIAN_VAULT_ID:-}" ]; then echo "WARNING: OBSIDIAN_EMAIL, OBSIDIAN_PASSWORD, or OBSIDIAN_VAULT_ID not set. Skipping sync." else - echo "Setting up Obsidian sync..." - VAULT_PATH="${OBSIDIAN_VAULT_PATH:-/app/data/obsidian}" + SYNC_MODE="${OBSIDIAN_SYNC_MODE:-bidirectional}" + mkdir -p "${VAULT_PATH}" - # Login and setup sync (foreground, must complete before sync starts) - if ob login --email "${OBSIDIAN_EMAIL}" --password "${OBSIDIAN_PASSWORD}" && \ - ob sync-setup \ - --vault "${OBSIDIAN_VAULT_ID}" \ - --path "${VAULT_PATH}" \ - --password "${OBSIDIAN_E2E_PASSWORD:-}" \ - --device-name "${OBSIDIAN_DEVICE_NAME:-simbarag}"; then - # Remove stale lock from previous container run - rm -rf "${VAULT_PATH}/.obsidian/.sync.lock" - # Set sync to pull-only (read-only) mode - ob sync-config --mode pull-only --path "${VAULT_PATH}" - # Start continuous sync in background - echo "Starting Obsidian continuous sync (pull-only)..." - ob sync --continuous --path "${VAULT_PATH}" & - else - echo "WARNING: Obsidian sync setup failed. Continuing without sync." - fi + # Blue and green share the vault volume. flock on a file in it works + # across containers, so this container only sets up and runs sync once + # the previous container's sync process has exited. Two clients writing + # one vault and its sync state would otherwise race. Setup runs in the + # background so the app starts while it waits. + ( + flock 9 + echo "Setting up Obsidian sync (${SYNC_MODE})..." + if ob login --email "${OBSIDIAN_EMAIL}" --password "${OBSIDIAN_PASSWORD}" && \ + ob sync-setup \ + --vault "${OBSIDIAN_VAULT_ID}" \ + --path "${VAULT_PATH}" \ + --password "${OBSIDIAN_E2E_PASSWORD:-}" \ + --device-name "${OBSIDIAN_DEVICE_NAME:-simbarag}"; then + # Safe now: the lock means no other container's sync is running. + rm -rf "${VAULT_PATH}/.obsidian/.sync.lock" + if ob sync-config --mode "${SYNC_MODE}" --conflict-strategy merge \ + --path "${VAULT_PATH}"; then + echo "Starting Obsidian continuous sync (${SYNC_MODE})..." + exec ob sync --continuous --path "${VAULT_PATH}" + fi + echo "WARNING: Obsidian sync-config failed. Continuing without sync." + else + echo "WARNING: Obsidian sync setup failed. Continuing without sync." + fi + ) 9>"${VAULT_PATH}/.raggr-sync.lock" & fi fi diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 8eb633e..3354260 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -63,6 +63,7 @@ def test_missing_required_settings(production_env, name): ("CHECKPOINT_DB_PATH", "/data/invalid\x00.db"), ("CONVERSATION_SUMMARY_DB_PATH", ""), ("OBSIDIAN_CONTINUOUS_SYNC", "yes"), + ("OBSIDIAN_SYNC_MODE", "push-only"), ], ) def test_malformed_and_unsafe_settings(production_env, name, value): @@ -202,6 +203,7 @@ def test_compose_forwards_runtime_settings(): "CHECKPOINT_DB_PATH", "CONVERSATION_SUMMARY_DB_PATH", "OBSIDIAN_SYNC_INTERVAL", + "OBSIDIAN_SYNC_MODE", "OIDC_AUTHORIZATION_ENDPOINT", "OIDC_TOKEN_ENDPOINT", "OIDC_USERINFO_ENDPOINT",