feat: add Docker first-start admin bootstrap
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s

This commit is contained in:
thakares committed 2026-08-12 14:13:09 +05:30
1 parent 25577b4b83
commit 8e0bcbe580
9 files changed
+152 -6

No files matched your search

+3 -3
View File
@@ -8,10 +8,10 @@ COOKIE_SECURE=false
SESSION_SECRET=bzod-default-session-secret-change-me-in-production-please-do-it SESSION_SECRET=bzod-default-session-secret-change-me-in-production-please-do-it
# Bootstrap Admin Credentials # Bootstrap Admin Credentials
# Default username: admin
# Default password: admin
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD_SHA256=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918 # REQUIRED for a fresh deployment.
# Use a strong unique password.
ADMIN_PASSWORD=
# Cron & Cleaner Intervals (in minutes) # Cron & Cleaner Intervals (in minutes)
LINK_CHECK_INTERVAL_MINS=60 LINK_CHECK_INTERVAL_MINS=60
+4 -2
View File
@@ -51,6 +51,7 @@ RUN groupadd --gid 1000 bzod && \
# Application binary # Application binary
COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Application-owned immutable assets # Application-owned immutable assets
COPY --from=builder /app/templates /app/templates COPY --from=builder /app/templates /app/templates
@@ -68,7 +69,8 @@ RUN mkdir -p \
/app/images \ /app/images \
/app/templates \ /app/templates \
/app/www \ /app/www \
/usr/local/bin/bzod /usr/local/bin/bzod \
/usr/local/bin/docker-entrypoint.sh
# Runtime configuration # Runtime configuration
ENV DATA_DIR=/app/data \ ENV DATA_DIR=/app/data \
@@ -89,5 +91,5 @@ HEALTHCHECK \
USER bzod USER bzod
ENTRYPOINT ["/usr/local/bin/bzod"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["serve"] CMD ["serve"]
+2
View File
@@ -14,6 +14,8 @@ services:
environment: environment:
- COOKIE_SECURE=false - COOKIE_SECURE=false
- DATA_DIR=/app/data - DATA_DIR=/app/data
- ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=8654 - PORT=8654
- RUST_LOG=info - RUST_LOG=info
+9
View File
@@ -0,0 +1,9 @@
#!/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 "$@"
+15 -1
View File
@@ -45,7 +45,21 @@ cd nx9-url-shortener
docker compose up -d --build docker compose up -d --build
``` ```
## Create Administrator ## Automated Administrator Bootstrap (First Start Only)
For fresh deployments, you can supply administrator credentials via environment variables so the container initializes the admin automatically:
```yaml
environment:
ADMIN_USERNAME: "admin"
ADMIN_PASSWORD: "<your-secure-password>"
```
These credentials are used **only** when no administrator exists. If an administrator is already present, this step is safely skipped and existing accounts are preserved.
## Manual Administrator Creation
Alternatively, if you prefer not to use environment variables, you can create the admin manually:
```bash ```bash
docker exec -it bzod bzod create-admin docker exec -it bzod bzod create-admin
+53
View File
@@ -0,0 +1,53 @@
use crate::auth::hash_password;
use crate::config::Config;
use crate::db::Db;
use std::env;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let conn = db.users.lock().unwrap();
let admin_count: i64 = conn.query_row(
"SELECT COUNT(*) FROM users WHERE account_type = 'admin';",
[],
|row| row.get(0),
)?;
if admin_count > 0 {
info!("Administrator already exists; initialization skipped.");
return Ok(());
}
let username = match env::var("ADMIN_USERNAME") {
Ok(u) if !u.trim().is_empty() => u.trim().to_string(),
_ => {
let msg = "No administrator exists.\nADMIN_USERNAME and ADMIN_PASSWORD are required for first-time initialization.";
error!("{}", msg);
return Err(msg.into());
}
};
let password = match env::var("ADMIN_PASSWORD") {
Ok(p) if !p.trim().is_empty() => p.trim().to_string(),
_ => {
let msg = "No administrator exists.\nADMIN_USERNAME and ADMIN_PASSWORD are required for first-time initialization.";
error!("{}", msg);
return Err(msg.into());
}
};
let hash = hash_password(&password).map_err(|e| e.to_string())?;
let u = crate::db::users::create_admin_user(&conn, &username, &hash)?;
db.init_user_databases(u.id)?;
info!("Administrator initialized successfully.");
Ok(())
}
+6
View File
@@ -11,6 +11,7 @@ pub mod disable_user;
pub mod doctor; pub mod doctor;
pub mod enable_user; pub mod enable_user;
pub mod expand; pub mod expand;
pub mod init_admin;
pub mod list_users; pub mod list_users;
pub mod migrate; pub mod migrate;
pub mod repair; pub mod repair;
@@ -86,6 +87,11 @@ pub enum Commands {
#[arg(long)] #[arg(long)]
data_dir: Option<String>, data_dir: Option<String>,
}, },
/// Initialize the first administrator for automated/container deployments
InitAdmin {
#[arg(long)]
data_dir: Option<String>,
},
/// Run database diagnostics and health checks /// Run database diagnostics and health checks
Doctor { Doctor {
#[arg(long)] #[arg(long)]
+3
View File
@@ -44,6 +44,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Commands::CreateAdmin { username, data_dir } => { Commands::CreateAdmin { username, data_dir } => {
bzod::cli::create_admin::run(username, data_dir, config).await?; bzod::cli::create_admin::run(username, data_dir, config).await?;
} }
Commands::InitAdmin { data_dir } => {
bzod::cli::init_admin::run(data_dir, config).await?;
}
Commands::Doctor { data_dir } => { Commands::Doctor { data_dir } => {
bzod::cli::doctor::run(data_dir, config).await?; bzod::cli::doctor::run(data_dir, config).await?;
} }
+57
View File
@@ -0,0 +1,57 @@
use bzod::config::Config;
use bzod::db::Db;
use std::env;
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();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_init_admin() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_init_admin_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
// Case C/D - missing env vars
env::remove_var("ADMIN_USERNAME");
env::remove_var("ADMIN_PASSWORD");
let res = bzod::cli::init_admin::run(None, config.clone()).await;
assert!(res.is_err(), "Should fail without env vars");
// Case A - No admin + valid ENV
env::set_var("ADMIN_USERNAME", "admin");
env::set_var("ADMIN_PASSWORD", "securepass");
let res = bzod::cli::init_admin::run(None, config.clone()).await;
assert!(res.is_ok(), "Should succeed with valid env vars");
let db = Db::init(&config).unwrap();
{
let conn = db.users.lock().unwrap();
let user = bzod::db::users::get_user_by_username(&conn, "admin")
.unwrap()
.unwrap();
assert_eq!(user.account_type, "admin");
assert!(bzod::auth::verify_password(
"securepass",
&user.password_hash
));
}
// Case B/F - Admin already exists
env::remove_var("ADMIN_USERNAME");
env::remove_var("ADMIN_PASSWORD");
let res = bzod::cli::init_admin::run(None, config.clone()).await;
assert!(
res.is_ok(),
"Should skip and succeed if admin exists even without env vars"
);
let _ = fs::remove_dir_all(&temp_dir);
}