4 Commits
Author SHA1 Message Date
thakares fb5d827322 chore: ignore local backups
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-27 20:54:05 +05:30
thakares feed352c2e fix: only initialize admin explicitly 2026-08-27 19:27:24 +05:30
thakares e42d1a5d80 fix: standardize deployment data directory and CI linting
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-27 15:41:24 +05:30
thakares c2e138f823 refactor(config): require explicit BZOD data directory
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-27 15:06:05 +05:30
58 changed files with 510 additions and 151 deletions

No files matched your search

+3 -1
View File
@@ -1,7 +1,9 @@
# BZOD Platform Configuration
HOST=0.0.0.0
PORT=8080
DATA_DIR=./data
# Physical BZOD data root.
# REQUIRED: Set this explicitly; there is no implicit ./data fallback.
NX9_BZOD_DATA_DIR=/var/lib/bzod/data
# Security Settings
COOKIE_SECURE=false
+3
View File
@@ -7,3 +7,6 @@ data/
.idea/
bzod.env
# Local database backups
backups/
+1 -1
View File
@@ -73,7 +73,7 @@ RUN mkdir -p \
/usr/local/bin/docker-entrypoint.sh
# Runtime configuration
ENV DATA_DIR=/app/data \
ENV NX9_BZOD_DATA_DIR=/app/data \
CONFIG_DIR=/app/config \
IMAGES_DIR=/app/images \
PORT=8654 \
+29 -5
View File
@@ -168,9 +168,10 @@ services:
- "8654:8654"
volumes:
- ./data:/app/data
- /var/lib/bzod/data:/app/data
environment:
- NX9_BZOD_DATA_DIR=/app/data
- RUST_LOG=info
```
@@ -182,6 +183,28 @@ docker compose up -d
---
# Data Directory & Configuration Precedence
BZOD requires an explicit physical data root. There is **no implicit `./data` fallback**.
Precedence:
1. **CLI `--data-dir`** (Highest)
2. **`NX9_BZOD_DATA_DIR`** environment variable
3. **`config.toml` / `bzod.toml` `data_dir`** setting
4. **Configuration Error** if no data directory is configured.
Examples:
```bash
# Via environment variable
NX9_BZOD_DATA_DIR=/var/lib/bzod/data bzod serve
# Via CLI argument
bzod migrate --data-dir=/var/lib/bzod/data --dry-run
```
---
# Native Installation
Download the latest release.
@@ -202,16 +225,16 @@ bzod --help
# Initialize
Create the administrator.
Create the administrator (specifying `--data-dir` or `NX9_BZOD_DATA_DIR`):
```bash
bzod create-admin
NX9_BZOD_DATA_DIR=/var/lib/bzod/data bzod create-admin
```
Start the server.
Start the server:
```bash
bzod serve
NX9_BZOD_DATA_DIR=/var/lib/bzod/data bzod serve
```
Default server:
@@ -234,6 +257,7 @@ After=network.target
[Service]
Environment=NX9_BZOD_DATA_DIR=/var/lib/bzod/data
ExecStart=/usr/local/bin/bzod serve
Restart=always
+8 -8
View File
@@ -31,7 +31,7 @@ BASE_URL="${BASE_URL:-https://bzo.in}"
CONTAINER_NAME="${CONTAINER_NAME:-bzod}"
DATA_DIR="${BZOD_ROOT}/data"
NX9_BZOD_DATA_DIR="${BZOD_ROOT}/data"
CONFIG_DIR="${BZOD_ROOT}/config"
IMAGES_DIR="${BZOD_ROOT}/images"
COMPOSE_DIR="${BZOD_ROOT}/compose"
@@ -89,7 +89,7 @@ echo
echo "Version: ${BZOD_VERSION}"
echo "Image: ${IMAGE}"
echo "Application: ${BZOD_ROOT}"
echo "Data: ${DATA_DIR}"
echo "Data: ${NX9_BZOD_DATA_DIR}"
echo "Config: ${CONFIG_DIR}"
echo "Images: ${IMAGES_DIR}"
echo "Port: ${BZOD_PORT}"
@@ -154,7 +154,7 @@ success "✓ Docker and Docker Compose available"
info "[2/8] Creating persistent application directories..."
mkdir -p \
"${DATA_DIR}" \
"${NX9_BZOD_DATA_DIR}" \
"${CONFIG_DIR}" \
"${IMAGES_DIR}" \
"${COMPOSE_DIR}" \
@@ -180,7 +180,7 @@ BZOD_IMAGE=${BZOD_IMAGE}
HOST=0.0.0.0
PORT=8654
DATA_DIR=/app/data
NX9_BZOD_DATA_DIR=/app/data
CONFIG_DIR=/app/config
IMAGES_DIR=/app/images
@@ -238,7 +238,7 @@ services:
HOST: "${HOST:-0.0.0.0}"
PORT: "${PORT:-8654}"
DATA_DIR: "/app/data"
NX9_BZOD_DATA_DIR: "/app/data"
CONFIG_DIR: "/app/config"
IMAGES_DIR: "/app/images"
@@ -300,8 +300,8 @@ BACKUP_DIR="${BACKUP_ROOT}/pre-upgrade-${TIMESTAMP}-v${BZOD_VERSION}"
mkdir -p "${BACKUP_DIR}"
if [[ -d "${DATA_DIR}" ]]; then
cp -a "${DATA_DIR}" "${BACKUP_DIR}/data"
if [[ -d "${NX9_BZOD_DATA_DIR}" ]]; then
cp -a "${NX9_BZOD_DATA_DIR}" "${BACKUP_DIR}/data"
fi
if [[ -d "${CONFIG_DIR}" ]]; then
@@ -451,7 +451,7 @@ echo " http://<server-ip>:${BZOD_PORT}"
echo
echo "Persistent data:"
echo " ${DATA_DIR}"
echo " ${NX9_BZOD_DATA_DIR}"
echo
echo "Persistent images:"
+1 -1
View File
@@ -18,7 +18,7 @@ services:
- ADMIN_USERNAME=${ADMIN_USERNAME}
- CONFIG_DIR=/app/config
- COOKIE_SECURE=false
- DATA_DIR=/app/data
- NX9_BZOD_DATA_DIR=/app/data
- HOST=0.0.0.0
- IMAGES_DIR=/app/images
- PORT=8654
-5
View File
@@ -1,9 +1,4 @@
#!/bin/sh
set -e
if [ "$1" = 'serve' ]; then
/usr/local/bin/bzod init-admin
exec /usr/local/bin/bzod serve
fi
exec /usr/local/bin/bzod "$@"
+24
View File
@@ -284,3 +284,27 @@ bzod doctor
* SECURITY.md
* API.md
* ARCHITECTURE.md
---
# Data Directory Configuration
BZOD requires an explicit physical data directory root. There is **no implicit `./data` fallback**.
### Configuration Precedence
1. **CLI `--data-dir`** (Highest priority)
```bash
bzod serve --data-dir /var/lib/bzod/data
bzod migrate --data-dir=/var/lib/bzod/data --dry-run
```
2. **Environment Variable `NX9_BZOD_DATA_DIR`**
```bash
export NX9_BZOD_DATA_DIR=/var/lib/bzod/data
bzod serve
```
3. **Configuration File (`bzod.toml` / `config.toml`)**
```toml
data_dir = "/var/lib/bzod/data"
```
4. **Error**: If no data directory is provided via CLI, `NX9_BZOD_DATA_DIR`, or config file, BZOD terminates with an actionable error.
+3 -3
View File
@@ -100,7 +100,7 @@ services:
environment:
HOST: 0.0.0.0
PORT: 8654
DATA_DIR: /app/data
NX9_BZOD_DATA_DIR: /app/data
COOKIE_SECURE: "false"
healthcheck:
@@ -205,10 +205,10 @@ BZOD Docker Container
# Environment Variables
| Variable | Description | Default |
| ------------- | ------------------ | --------- |
| ----------------- | ------------------ | ------------- |
| HOST | Bind address | 0.0.0.0 |
| PORT | Listen port | 8654 |
| DATA_DIR | Database directory | /app/data |
| NX9_BZOD_DATA_DIR | Database directory | (required) |
| COOKIE_SECURE | Secure cookies | false |
| RUST_LOG | Logging level | info |
+31
View File
@@ -213,3 +213,34 @@ pub enum RepairCommands {
data_dir: Option<String>,
},
}
impl Commands {
pub fn data_dir(&self) -> Option<&str> {
match self {
Commands::Serve { data_dir, .. } => data_dir.as_deref(),
Commands::Backup { data_dir, .. } => data_dir.as_deref(),
Commands::Restore { data_dir, .. } => data_dir.as_deref(),
Commands::Migrate { data_dir, .. } => data_dir.as_deref(),
Commands::Stats { data_dir, .. } => data_dir.as_deref(),
Commands::Validate { data_dir, .. } => data_dir.as_deref(),
Commands::AuditDestinations { data_dir, .. } => data_dir.as_deref(),
Commands::CreateAdmin { data_dir, .. } => data_dir.as_deref(),
Commands::InitAdmin { data_dir, .. } => data_dir.as_deref(),
Commands::Doctor { data_dir, .. } => data_dir.as_deref(),
Commands::Shorten { data_dir, .. } => data_dir.as_deref(),
Commands::Expand { data_dir, .. } => data_dir.as_deref(),
Commands::CreateUser { data_dir, .. } => data_dir.as_deref(),
Commands::DeleteUser { data_dir, .. } => data_dir.as_deref(),
Commands::DisableUser { data_dir, .. } => data_dir.as_deref(),
Commands::EnableUser { data_dir, .. } => data_dir.as_deref(),
Commands::ResetPassword { data_dir, .. } => data_dir.as_deref(),
Commands::ListUsers { data_dir, .. } => data_dir.as_deref(),
Commands::BackupUser { data_dir, .. } => data_dir.as_deref(),
Commands::RestoreUser { data_dir, .. } => data_dir.as_deref(),
Commands::AdminMigrate { data_dir, .. } => data_dir.as_deref(),
Commands::Repair { command } => match command {
RepairCommands::Registry { data_dir, .. } => data_dir.as_deref(),
},
}
}
}
+1 -2
View File
@@ -277,8 +277,7 @@ pub fn perform_restore(
}
// 5. Run validation on the normalized temp_dir
let mut temp_config = Config::load();
temp_config.data_dir = temp_dir.clone();
let temp_config = Config::load_with_cli(Some(&temp_dir))?;
// Namespace audit
match crate::db::users::audit_slug_namespace(&temp_config) {
+101 -15
View File
@@ -3,6 +3,45 @@ use std::env;
use std::fs;
use std::path::PathBuf;
pub const NX9_BZOD_DATA_DIR_ENV: &str = "NX9_BZOD_DATA_DIR";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigError {
MissingDataDir,
InvalidConfig(String),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingDataDir => write!(
f,
"data directory is not configured; set NX9_BZOD_DATA_DIR or use --data-dir=<path>"
),
Self::InvalidConfig(msg) => write!(f, "configuration error: {msg}"),
}
}
}
impl std::error::Error for ConfigError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DataDirSource {
Cli,
Env,
ConfigFile,
}
impl std::fmt::Display for DataDirSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Cli => write!(f, "CLI"),
Self::Env => write!(f, "NX9_BZOD_DATA_DIR"),
Self::ConfigFile => write!(f, "config.toml"),
}
}
}
#[derive(Clone, Debug)]
pub struct Config {
pub host: String,
@@ -45,11 +84,23 @@ struct TomlBackupConfig {
}
impl Config {
pub fn load() -> Self {
// 1. Built-in defaults
pub fn load() -> Result<Self, ConfigError> {
Self::load_with_cli(None::<&std::path::Path>)
}
pub fn load_with_cli<P: AsRef<std::path::Path>>(
cli_data_dir: Option<P>,
) -> Result<Self, ConfigError> {
Self::load_from_sources(cli_data_dir, true)
}
pub fn load_from_sources<P: AsRef<std::path::Path>>(
cli_data_dir: Option<P>,
load_dotenv: bool,
) -> Result<Self, ConfigError> {
// 1. Built-in defaults (no implicit data_dir default)
let mut host = "0.0.0.0".to_string();
let mut port = 8080u16;
let mut data_dir = PathBuf::from("./data");
let mut admin_username = "admin".to_string();
let mut bootstrap_password_sha256 = "".to_string();
let mut session_secret =
@@ -63,12 +114,18 @@ impl Config {
let mut backup_dir = PathBuf::from("./backups");
let mut base_url = None;
// 2. Load bzod.toml if it exists
let mut toml_path = "bzod.toml".to_string();
if fs::metadata("bzod.toml").is_err() && fs::metadata("config/bzod.toml").is_ok() {
toml_path = "config/bzod.toml".to_string();
}
if let Ok(toml_content) = fs::read_to_string(&toml_path) {
let mut resolved_data_dir: Option<PathBuf> = None;
let mut data_dir_source: Option<DataDirSource> = None;
// 2. Load bzod.toml / config.toml if it exists
let possible_tomls = [
"bzod.toml",
"config/bzod.toml",
"config.toml",
"config/config.toml",
];
for toml_path in possible_tomls {
if let Ok(toml_content) = fs::read_to_string(toml_path) {
if let Ok(toml_config) = toml::from_str::<TomlConfig>(&toml_content) {
if let Some(h) = toml_config.host {
host = h;
@@ -77,7 +134,10 @@ impl Config {
port = p;
}
if let Some(d) = toml_config.data_dir {
data_dir = PathBuf::from(d);
if !d.trim().is_empty() {
resolved_data_dir = Some(PathBuf::from(d));
data_dir_source = Some(DataDirSource::ConfigFile);
}
}
if let Some(u) = toml_config.admin_username {
admin_username = u;
@@ -118,16 +178,20 @@ impl Config {
if let Some(bu) = toml_config.base_url {
base_url = Some(bu);
}
break;
}
}
}
// 3. Load .env if present
if load_dotenv {
let _ = dotenvy::dotenv();
if fs::metadata("config/.env").is_ok() {
let _ = dotenvy::from_path("config/.env");
}
}
// 4. Load from Environment Variables (taking highest precedence)
// 4. Load from Environment Variables (taking precedence over config file)
if let Ok(h) = env::var("HOST") {
host = h;
}
@@ -136,8 +200,11 @@ impl Config {
port = p;
}
}
if let Ok(d_str) = env::var("DATA_DIR") {
data_dir = PathBuf::from(d_str);
if let Ok(d_str) = env::var(NX9_BZOD_DATA_DIR_ENV) {
if !d_str.trim().is_empty() {
resolved_data_dir = Some(PathBuf::from(d_str));
data_dir_source = Some(DataDirSource::Env);
}
}
if let Ok(u) = env::var("ADMIN_USERNAME") {
admin_username = u;
@@ -187,7 +254,26 @@ impl Config {
base_url = Some(bu);
}
Self {
// 5. CLI override (highest precedence)
if let Some(cli_dir) = cli_data_dir {
let path_ref = cli_dir.as_ref();
if !path_ref.as_os_str().is_empty() {
resolved_data_dir = Some(path_ref.to_path_buf());
data_dir_source = Some(DataDirSource::Cli);
}
}
let data_dir = match resolved_data_dir {
Some(dir) => dir,
None => return Err(ConfigError::MissingDataDir),
};
if let Some(source) = data_dir_source {
tracing::info!("Data directory: {}", data_dir.display());
tracing::info!("Data directory source: {}", source);
}
Ok(Self {
host,
port,
data_dir,
@@ -202,6 +288,6 @@ impl Config {
backup_interval_mins,
backup_dir,
base_url,
}
})
}
}
+2 -4
View File
@@ -388,16 +388,14 @@ impl Db {
#[cfg(test)]
mod db_init_tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_db_init() {
let temp_dir = PathBuf::from("./temp_test_db_dir");
let temp_dir = std::env::temp_dir().join(format!("test_db_init_{}", uuid::Uuid::new_v4()));
if temp_dir.exists() {
let _ = std::fs::remove_dir_all(&temp_dir);
}
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let config = Config::load_with_cli(Some(&temp_dir)).unwrap();
let db = Db::init(&config);
// Cleanup
+1 -1
View File
@@ -10,7 +10,7 @@
//! └── extensions/<extension>/<extension>.db
//! ```
//!
//! `<data_dir>` is `Config.data_dir` (env `DATA_DIR`, default `./data`).
//! `<data_dir>` is `Config.data_dir` (CLI `--data-dir`, env `NX9_BZOD_DATA_DIR`, or config file).
//! It is not renamed to `database/`. Production Docker, CasaOS, and
//! `deploy.sh` bind this physical root.
+7 -1
View File
@@ -13,7 +13,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.init();
let cli = Cli::parse();
let config = Config::load();
let config = match Config::load_with_cli(cli.command.data_dir()) {
Ok(config) => config,
Err(err) => {
eprintln!("Error: {err}");
std::process::exit(1);
}
};
match cli.command {
Commands::Serve {
+23 -15
View File
@@ -90,10 +90,10 @@ pub(crate) const MAX_JSON_EXPORT_ROWS: usize = 50_000;
pub(crate) async fn require_auth(
state: &AppState,
jar: &CookieJar,
) -> Result<(User, String), Response> {
) -> Result<(User, String), Box<Response>> {
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => return Err(Redirect::to("/admin/login").into_response()),
Err(_) => return Err(Box::new(Redirect::to("/admin/login").into_response())),
};
match authenticate_admin_session(&conn, jar) {
@@ -102,16 +102,18 @@ pub(crate) async fn require_auth(
// Check if user is logged in as a normal tenant user trying to access admin route
if let Ok(Some((tenant_user, _))) = authenticate_user_session(&conn, jar) {
if tenant_user.account_type != "admin" {
return Err((
return Err(Box::new(
(
StatusCode::FORBIDDEN,
"Forbidden: Standard users cannot access Admin routes",
)
.into_response());
.into_response(),
));
}
}
Err(Redirect::to("/admin/login").into_response())
Err(Box::new(Redirect::to("/admin/login").into_response()))
}
Err(_) => Err(Redirect::to("/admin/login").into_response()),
Err(_) => Err(Box::new(Redirect::to("/admin/login").into_response())),
}
}
@@ -119,41 +121,47 @@ pub(crate) async fn require_auth(
pub(crate) async fn require_user_auth(
state: &AppState,
jar: &CookieJar,
) -> Result<(crate::models::TenantUser, String), Response> {
) -> Result<(crate::models::TenantUser, String), Box<Response>> {
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => return Err(Redirect::to("/login").into_response()),
Err(_) => return Err(Box::new(Redirect::to("/login").into_response())),
};
// If an Admin session is present, Core Admin is trying to access /user/* routes -> Reject with 403 Forbidden
if let Ok(Some((_admin_user, _))) = authenticate_admin_session(&conn, jar) {
return Err((
return Err(Box::new(
(
StatusCode::FORBIDDEN,
"Forbidden: Core Admin cannot access tenant application routes; use /admin/...",
)
.into_response());
.into_response(),
));
}
// Now check tenant user session
match authenticate_user_session(&conn, jar) {
Ok(Some((user, session_id))) => {
if user.account_type == "admin" {
return Err((
return Err(Box::new(
(
StatusCode::FORBIDDEN,
"Forbidden: Core Admin cannot access tenant application routes; use /admin/...",
)
.into_response());
.into_response(),
));
}
if user.tenant_id.is_none() {
return Err((
return Err(Box::new(
(
StatusCode::FORBIDDEN,
"Forbidden: User has no assigned TenantId",
)
.into_response());
.into_response(),
));
}
Ok((user, session_id))
}
_ => Err(Redirect::to("/login").into_response()),
_ => Err(Box::new(Redirect::to("/login").into_response())),
}
}
#[derive(Deserialize)]
+1 -2
View File
@@ -4,8 +4,7 @@ use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+2 -3
View File
@@ -19,10 +19,9 @@ use bzod::web::admin::{
};
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://localhost:8080".to_string());
config.base_url = Some("http://bzo.in".to_string());
config
}
+1 -2
View File
@@ -18,8 +18,7 @@ fn compute_sha256(value: &str) -> String {
}
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
+1 -2
View File
@@ -21,8 +21,7 @@ use bzod::db::Db;
use bzod::web::admin::{dashboard_get, login_post, LoginForm};
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
+1 -2
View File
@@ -7,8 +7,7 @@ use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+2 -4
View File
@@ -7,8 +7,7 @@ use tar::Archive;
use zstd::Decoder;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
@@ -200,8 +199,7 @@ async fn test_restore_slug_collision_rejection() {
));
fs::create_dir_all(&temp_dir).unwrap();
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.join("backups");
fs::create_dir_all(&config.backup_dir).unwrap();
+1 -2
View File
@@ -18,8 +18,7 @@ fn compute_sha256(value: &str) -> String {
}
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
+1 -2
View File
@@ -6,8 +6,7 @@ use std::sync::Arc;
use tokio::sync::Barrier;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+222
View File
@@ -0,0 +1,222 @@
use bzod::config::{Config, ConfigError, NX9_BZOD_DATA_DIR_ENV};
use bzod::db::topology::Topology;
use bzod::identity::TenantId;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
// Global lock to prevent race conditions during env-var testing
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard {
key: &'static str,
original_val: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, val: &str) -> Self {
let original_val = env::var(key).ok();
env::set_var(key, val);
Self { key, original_val }
}
fn unset(key: &'static str) -> Self {
let original_val = env::var(key).ok();
env::remove_var(key);
Self { key, original_val }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(ref val) = self.original_val {
env::set_var(self.key, val);
} else {
env::remove_var(self.key);
}
}
}
#[test]
fn test_no_configuration_returns_missing_data_dir_error() {
let _lock = ENV_LOCK.lock().unwrap();
let _guard = EnvGuard::unset(NX9_BZOD_DATA_DIR_ENV);
// Call load_from_sources with no CLI and load_dotenv=false in an isolated context
let res = Config::load_from_sources(None::<&Path>, false);
assert!(
res.is_err(),
"Expected error when no data directory is configured"
);
match res.err().unwrap() {
ConfigError::MissingDataDir => {}
other => panic!("Expected ConfigError::MissingDataDir, got {:?}", other),
}
}
#[test]
fn test_environment_variable_resolves_data_dir() {
let _lock = ENV_LOCK.lock().unwrap();
let test_path = "/tmp/nx9-env-test-data";
let _guard = EnvGuard::set(NX9_BZOD_DATA_DIR_ENV, test_path);
let config = Config::load_from_sources(None::<&Path>, false)
.expect("Config should load with NX9_BZOD_DATA_DIR set");
assert_eq!(config.data_dir, PathBuf::from(test_path));
}
#[test]
fn test_config_file_resolves_data_dir() {
let _lock = ENV_LOCK.lock().unwrap();
let _guard = EnvGuard::unset(NX9_BZOD_DATA_DIR_ENV);
let temp_dir = std::env::temp_dir().join(format!("bzod_cfg_test_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let toml_file = temp_dir.join("bzod.toml");
let target_data_dir = "/tmp/config-file-data";
fs::write(&toml_file, format!("data_dir = \"{}\"\n", target_data_dir)).unwrap();
// Change current directory temporarily for config file reading
let original_cwd = env::current_dir().unwrap();
env::set_current_dir(&temp_dir).unwrap();
let config_res = Config::load_from_sources(None::<&Path>, false);
// Restore cwd
env::set_current_dir(original_cwd).unwrap();
let _ = fs::remove_dir_all(&temp_dir);
let config = config_res.expect("Config should load from bzod.toml");
assert_eq!(config.data_dir, PathBuf::from(target_data_dir));
}
#[test]
fn test_environment_overrides_config_file() {
let _lock = ENV_LOCK.lock().unwrap();
let env_data_dir = "/tmp/env-override-data";
let _guard = EnvGuard::set(NX9_BZOD_DATA_DIR_ENV, env_data_dir);
let temp_dir = std::env::temp_dir().join(format!("bzod_cfg_test_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let toml_file = temp_dir.join("bzod.toml");
let config_data_dir = "/tmp/config-file-data";
fs::write(&toml_file, format!("data_dir = \"{}\"\n", config_data_dir)).unwrap();
let original_cwd = env::current_dir().unwrap();
env::set_current_dir(&temp_dir).unwrap();
let config_res = Config::load_from_sources(None::<&Path>, false);
env::set_current_dir(original_cwd).unwrap();
let _ = fs::remove_dir_all(&temp_dir);
let config = config_res.expect("Config should load");
assert_eq!(config.data_dir, PathBuf::from(env_data_dir));
}
#[test]
fn test_cli_overrides_environment() {
let _lock = ENV_LOCK.lock().unwrap();
let env_data_dir = "/tmp/env-data";
let _guard = EnvGuard::set(NX9_BZOD_DATA_DIR_ENV, env_data_dir);
let cli_data_dir = "/tmp/cli-override-data";
let config = Config::load_from_sources(Some(cli_data_dir), false)
.expect("Config should load with CLI override");
assert_eq!(config.data_dir, PathBuf::from(cli_data_dir));
}
#[test]
fn test_cli_overrides_config_file() {
let _lock = ENV_LOCK.lock().unwrap();
let _guard = EnvGuard::unset(NX9_BZOD_DATA_DIR_ENV);
let temp_dir = std::env::temp_dir().join(format!("bzod_cfg_test_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let toml_file = temp_dir.join("bzod.toml");
let config_data_dir = "/tmp/config-file-data";
fs::write(&toml_file, format!("data_dir = \"{}\"\n", config_data_dir)).unwrap();
let original_cwd = env::current_dir().unwrap();
env::set_current_dir(&temp_dir).unwrap();
let cli_data_dir = "/tmp/cli-data-highest";
let config_res = Config::load_from_sources(Some(cli_data_dir), false);
env::set_current_dir(original_cwd).unwrap();
let _ = fs::remove_dir_all(&temp_dir);
let config = config_res.expect("Config should load with CLI override");
assert_eq!(config.data_dir, PathBuf::from(cli_data_dir));
}
#[test]
fn test_relative_path_semantics_preserved() {
let _lock = ENV_LOCK.lock().unwrap();
let relative_path = "custom_relative/data";
let config = Config::load_from_sources(Some(relative_path), false)
.expect("Relative path in CLI should be accepted as-is without forced canonicalization");
assert_eq!(config.data_dir, PathBuf::from(relative_path));
}
#[test]
fn test_topology_resolves_all_databases_relative_to_configured_root() {
let custom_root = PathBuf::from("/var/lib/bzod/custom_root");
let topology = Topology::new(&custom_root);
assert_eq!(topology.root(), &custom_root);
assert_eq!(topology.admin_dir(), custom_root.join("admin"));
assert_eq!(topology.slugs_dir(), custom_root.join("slugs"));
assert_eq!(topology.users_dir(), custom_root.join("users"));
// Core databases
assert_eq!(topology.admin_db(), custom_root.join("admin/admin.db"));
assert_eq!(topology.system_db(), custom_root.join("admin/system.db"));
assert_eq!(
topology.users_registry_db(),
custom_root.join("admin/users.db")
);
assert_eq!(
topology.global_urls_db(),
custom_root.join("slugs/global_urls.db")
);
assert_eq!(
topology.global_landing_pages_db(),
custom_root.join("slugs/global_landing_pages.db")
);
assert_eq!(
topology.reserved_db(),
custom_root.join("slugs/reserved.db")
);
// Tenant databases
let tenant_id = TenantId::parse("a1b2c3d4e5f6").unwrap();
assert_eq!(
topology.tenant_dir(tenant_id),
custom_root.join("users/a1b2c3d4e5f6")
);
assert_eq!(
topology.tenant_content_db(tenant_id),
custom_root.join("users/a1b2c3d4e5f6/content.db")
);
assert_eq!(
topology.tenant_analytics_db(tenant_id),
custom_root.join("users/a1b2c3d4e5f6/analytics.db")
);
assert_eq!(
topology.tenant_profile_db(tenant_id),
custom_root.join("users/a1b2c3d4e5f6/profile.db")
);
assert_eq!(
topology.extension_db("a1b2c3d4e5f6", "cv").unwrap(),
custom_root.join("users/a1b2c3d4e5f6/extensions/cv/cv.db")
);
}
+1 -2
View File
@@ -4,8 +4,7 @@ use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+1 -2
View File
@@ -18,8 +18,7 @@ fn compute_sha256(value: &str) -> String {
}
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
+1 -2
View File
@@ -5,8 +5,7 @@ use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+1 -2
View File
@@ -4,8 +4,7 @@ use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+1 -2
View File
@@ -21,8 +21,7 @@ use std::path::PathBuf;
use tar::Builder;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+1 -2
View File
@@ -4,8 +4,7 @@ use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+1 -2
View File
@@ -6,8 +6,7 @@ use std::path::PathBuf;
use uuid::Uuid;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+1 -2
View File
@@ -34,8 +34,7 @@ fn test_custom_slug_validation() {
}
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+1 -2
View File
@@ -18,8 +18,7 @@ fn compute_sha256(value: &str) -> String {
}
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
+1 -2
View File
@@ -18,8 +18,7 @@ fn compute_sha256(value: &str) -> String {
}
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
+1 -2
View File
@@ -4,8 +4,7 @@ use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+1 -2
View File
@@ -29,8 +29,7 @@ fn compute_sha256(value: &str) -> String {
}
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
+1 -2
View File
@@ -5,8 +5,7 @@ use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+1 -2
View File
@@ -11,8 +11,7 @@ use std::path::PathBuf;
use std::time::Instant;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+1 -2
View File
@@ -4,8 +4,7 @@ use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+1 -2
View File
@@ -6,8 +6,7 @@ use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+1 -2
View File
@@ -5,8 +5,7 @@ use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+1 -2
View File
@@ -4,8 +4,7 @@ use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+1 -2
View File
@@ -4,8 +4,7 @@ use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+1 -2
View File
@@ -19,8 +19,7 @@ fn compute_sha256(value: &str) -> String {
}
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
+1 -2
View File
@@ -4,8 +4,7 @@ use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+1 -2
View File
@@ -4,8 +4,7 @@ use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+1 -2
View File
@@ -4,8 +4,7 @@ use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+1 -2
View File
@@ -50,8 +50,7 @@ impl TestHarness {
let temp_dir =
std::env::temp_dir().join(format!("bzod_admin_boundary_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.join("backups");
config.base_url = Some("http://localhost:8080".to_string());
+1 -2
View File
@@ -13,8 +13,7 @@ use bzod::state::AppState;
use bzod::web::create_router;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.join("backups");
config.base_url = Some("http://localhost:8080".to_string());
config
+1 -2
View File
@@ -55,8 +55,7 @@ impl TestHarness {
let temp_dir =
std::env::temp_dir().join(format!("bzod_user_boundary_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.join("backups");
config.base_url = Some("http://localhost:8080".to_string());
+1 -2
View File
@@ -12,8 +12,7 @@ use std::path::PathBuf;
fn temp_config() -> (PathBuf, Config) {
let temp_dir = std::env::temp_dir().join(format!("bzod_v08_topology_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.join("backups");
(temp_dir, config)
}
+1 -2
View File
@@ -25,8 +25,7 @@ fn temp_config() -> (PathBuf, Config) {
let temp_dir =
std::env::temp_dir().join(format!("bzod_v08_id_migrate_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.join("backups");
(temp_dir, config)
}
+1 -2
View File
@@ -20,8 +20,7 @@ use uuid::Uuid;
fn create_test_config() -> (PathBuf, Config) {
let temp_dir = std::env::temp_dir().join(format!("bzod_p6a_test_{}", Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.join("backups");
config.base_url = Some("http://localhost:8080".to_string());
(temp_dir, config)
+1 -2
View File
@@ -25,8 +25,7 @@ fn temp_config() -> (PathBuf, Config) {
let temp_dir =
std::env::temp_dir().join(format!("bzod_v08_slug_migrate_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.join("backups");
(temp_dir, config)
}
+1 -2
View File
@@ -15,8 +15,7 @@ use std::time::Instant;
fn temp_config() -> (PathBuf, Config) {
let temp_dir = std::env::temp_dir().join(format!("bzod_v08_boundary_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.join("backups");
(temp_dir, config)
}
+1 -2
View File
@@ -4,8 +4,7 @@ use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let mut config = Config::load_with_cli(Some(&temp_dir)).unwrap();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
+8 -6
View File
@@ -7,7 +7,7 @@ set -euo pipefail
SERVICE_USER="bzod"
INSTALL_PATH="/usr/local/bin/bzod"
CONFIG_DIR="/etc/bzod"
DATA_DIR="/var/lib/bzod/data"
NX9_BZOD_DATA_DIR="/var/lib/bzod/data"
ENV_FILE="${CONFIG_DIR}/bzod.env"
SYSTEMD_UNIT="/etc/systemd/system/bzod.service"
@@ -110,7 +110,7 @@ fi
# 4. Setup Directories
echo -e "\n${BLUE}[4/8] Setting up directories...${NC}"
mkdir -p "${CONFIG_DIR}" "${DATA_DIR}"
mkdir -p "${CONFIG_DIR}" "${NX9_BZOD_DATA_DIR}"
chown -R "${SERVICE_USER}:${SERVICE_USER}" "/var/lib/bzod"
chmod 700 "${CONFIG_DIR}"
@@ -121,7 +121,7 @@ if [ ! -f "${ENV_FILE}" ]; then
cat <<EOF > "${ENV_FILE}"
HOST=0.0.0.0
PORT=8654
DATA_DIR=${DATA_DIR}
NX9_BZOD_DATA_DIR=${NX9_BZOD_DATA_DIR}
COOKIE_SECURE=true
RUST_LOG=info
SESSION_SECRET=$(openssl rand -hex 32)
@@ -175,11 +175,13 @@ systemctl daemon-reload
# 7. Initialize & Start
echo -e "\n${BLUE}[7/8] Initializing and starting service...${NC}"
if [ ! -f "${DATA_DIR}/content.db" ] && [ ! -f "${DATA_DIR}/admin.db" ] && [ ! -f "${DATA_DIR}/analytics.db" ]; then
runuser -u "${SERVICE_USER}" -- "${INSTALL_PATH}" init-db --data-dir "${DATA_DIR}"
echo -e "${GREEN}✓ Databases initialized${NC}"
if [ ! -f "${NX9_BZOD_DATA_DIR}/admin/users.db" ] && [ ! -f "${NX9_BZOD_DATA_DIR}/admin.db" ]; then
runuser -u "${SERVICE_USER}" -- "${INSTALL_PATH}" migrate --data-dir "${NX9_BZOD_DATA_DIR}"
echo -e "${GREEN}✓ Database topology initialized${NC}"
else
echo -e "${GREEN}✓ Existing database detected (upgrade mode)${NC}"
runuser -u "${SERVICE_USER}" -- "${INSTALL_PATH}" migrate --data-dir "${NX9_BZOD_DATA_DIR}"
echo -e "${GREEN}✓ Migrations verified${NC}"
fi
systemctl enable --now bzod