Files
momentry_core/src/bin/release.rs
Accusys c0c0e6e8ea feat: self-contained deploy/verify scripts in release package
- Add deploy.sh: imports data.sql, copies video, copies output files, verifies
- Add verify.sh: checks file integrity + DB/offline status
- Both scripts included in tar.gz via release package command
- Package now deployable standalone without release CLI
2026-05-13 04:35:43 +08:00

664 lines
24 KiB
Rust

//! Release Manager — deploy/undeploy/list video packages.
//! Binary: `cargo run --bin release -- <command>`
use anyhow::{Context, Result};
use chrono::Utc;
use clap::{Parser, Subcommand};
use momentry_core::core::config;
use momentry_core::core::db::PostgresDb;
use sqlx::Row;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
const DEMO_DIR: &str = "/Users/accusys/momentry/var/sftpgo/data/demo";
const OUTPUT_DIR: &str = "/Users/accusys/momentry/output_dev";
const RELEASE_DIR: &str = "/Users/accusys/momentry_core_0.1/release/files";
const PG_BIN: &str = "/Users/accusys/pgsql/18.3/bin";
#[derive(Parser)]
#[command(name = "release", about = "Release Manager — deploy/undeploy video packages")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Deploy a release package (.tar.gz)
Deploy {
/// Path to .tar.gz package
tarball: String,
},
/// Undeploy (remove all data for a video UUID)
Undeploy {
/// File UUID
uuid: String,
/// Skip confirmation
#[arg(short = 'y', long)]
yes: bool,
},
/// List deployed videos
List,
/// Create release package for a deployed video
Package {
/// File UUID
uuid: String,
},
/// Show package contents and statistics
Stats,
/// Generate visual reports from video data
Visualize {
/// File UUID
uuid: String,
/// Visualization type: heatmap, timeline
#[arg(short, long, default_value = "heatmap")]
typ: String,
/// Output path (default: output_dev/<uuid>_heatmap.html)
#[arg(short, long)]
output: Option<String>,
/// Filter by identity_id
#[arg(short = 'i', long)]
identity: Option<i64>,
},
/// Generate offline report from .sqlite file (no PostgreSQL needed)
VisualizeOffline {
/// Path to .sqlite file
sqlite_path: String,
/// Output path
#[arg(short, long)]
output: Option<String>,
/// Filter by identity_id
#[arg(short = 'i', long)]
identity: Option<i64>,
},
}
/// Run psql command and return stdout
fn psql_exec(sql: &str) -> Result<String> {
let output = Command::new(format!("{}/psql", PG_BIN))
.args(["-U", "accusys", "-d", "momentry", "-t", "-A", "-c", sql])
.output()
.context("psql command failed")?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// Run a SQL file via psql
fn psql_file(path: &Path) -> Result<()> {
let status = Command::new(format!("{}/psql", PG_BIN))
.args(["-U", "accusys", "-d", "momentry", "-f"])
.arg(path)
.status()
.context("psql file execution failed")?;
if !status.success() {
anyhow::bail!("psql returned non-zero exit code");
}
Ok(())
}
/// Extract tar.gz archive to a temp directory, return the top-level dir
fn extract_tarball(tarball: &Path) -> Result<PathBuf> {
let tmpdir = std::env::temp_dir().join(format!("release_{}", Utc::now().timestamp()));
fs::create_dir_all(&tmpdir)?;
let status = Command::new("tar")
.args(["-xzf", tarball.to_str().unwrap(), "-C", tmpdir.to_str().unwrap()])
.status()
.context("tar extraction failed")?;
if !status.success() {
anyhow::bail!("tar returned non-zero");
}
// Find the UUID directory (first subdir)
for entry in fs::read_dir(&tmpdir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
return Ok(entry.path());
}
}
anyhow::bail!("no directory found in tarball");
}
/// Get file_info.json from package directory
fn read_file_info(pkg_dir: &Path) -> Result<serde_json::Value> {
let info_path = pkg_dir.join("file_info.json");
let content = fs::read_to_string(&info_path)
.with_context(|| format!("Cannot read {:?}", info_path))?;
serde_json::from_str(&content).context("Invalid file_info.json")
}
// ---- Deploy ----
async fn cmd_deploy(db: &PostgresDb, tarball: &str) -> Result<()> {
let tarball_path = Path::new(tarball);
if !tarball_path.exists() {
anyhow::bail!("File not found: {}", tarball);
}
println!("=== Deploy: {} ===", tarball_path.file_name().unwrap().to_str().unwrap());
// Extract
let pkg_dir = extract_tarball(tarball_path)?;
println!("Extracted to {:?}", pkg_dir);
// Read file_info
let info = read_file_info(&pkg_dir)?;
let uuid = info["file_uuid"].as_str().context("Missing file_uuid in file_info.json")?;
let file_name = info["file_name"].as_str().unwrap_or("?");
println!("UUID: {}\nVideo: {}", uuid, file_name);
// Import data.sql
let sql_path = pkg_dir.join("data.sql");
if sql_path.exists() {
let size = fs::metadata(&sql_path)?.len();
println!("Importing data.sql ({} MB)...", size / 1024 / 1024);
psql_file(&sql_path)?;
println!(" SQL imported OK");
} else {
println!(" No data.sql in package");
}
// Copy video to demo dir
for entry in fs::read_dir(&pkg_dir)? {
let entry = entry?;
let fname = entry.file_name();
let fname_str = fname.to_str().unwrap_or("");
if fname_str.ends_with(".mp4") || fname_str.ends_with(".mov") || fname_str.ends_with(".avi") {
let dest = Path::new(DEMO_DIR).join(&fname);
if !dest.exists() {
fs::copy(entry.path(), &dest)?;
println!("Video: {}{}", fname_str, DEMO_DIR);
} else {
println!("Video: {} already in demo dir", fname_str);
}
}
}
// Copy output JSONs
for entry in fs::read_dir(&pkg_dir)? {
let entry = entry?;
let fname = entry.file_name();
let fname_str = fname.to_str().unwrap_or("");
if fname_str.ends_with(".json") && fname_str != "file_info.json" {
let dest = Path::new(OUTPUT_DIR).join(&fname);
fs::copy(entry.path(), &dest)?;
}
}
println!("Output files copied to {}", OUTPUT_DIR);
// Verify
let chunk_count: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM dev.chunk WHERE file_uuid = $1"
).bind(uuid).fetch_one(db.pool()).await?;
let face_count: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM dev.face_detections WHERE file_uuid = $1"
).bind(uuid).fetch_one(db.pool()).await?;
// Cleanup
fs::remove_dir_all(&pkg_dir.parent().unwrap_or(&pkg_dir))?;
println!("\n=== Deploy Complete ===");
println!(" Video: {}", file_name);
println!(" Chunks: {}", chunk_count.0);
println!(" Face detections: {}", face_count.0);
Ok(())
}
// ---- Undeploy ----
async fn cmd_undeploy(db: &PostgresDb, uuid: &str, skip_confirm: bool) -> Result<()> {
// Get video info
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT file_name, file_path FROM dev.videos WHERE file_uuid = $1"
).bind(uuid).fetch_all(db.pool()).await?;
if rows.is_empty() {
anyhow::bail!("UUID {} not found in DB", uuid);
}
let (file_name, file_path) = &rows[0];
println!("=== Undeploy: {} ===", uuid);
println!("Video: {}", file_name);
println!("This will DELETE all data for this video.");
if !skip_confirm {
print!("Continue? (y/N): ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if input.trim().to_lowercase() != "y" {
println!("Cancelled");
return Ok(());
}
}
// Delete DB data
let tables = [
("dev.chunk", "file_uuid"),
("dev.chunk_vectors", "uuid"),
("dev.face_detections", "file_uuid"),
("dev.processor_results", "file_uuid"),
("dev.monitor_jobs", "uuid"),
("dev.pre_chunks", "file_uuid"),
];
for (tbl, col) in &tables {
let sql = format!("DELETE FROM {} WHERE {} = $1", tbl, col);
let result = sqlx::query(&sql).bind(uuid).execute(db.pool()).await?;
println!(" {}: {} rows deleted", tbl, result.rows_affected());
}
sqlx::query("DELETE FROM dev.videos WHERE file_uuid = $1")
.bind(uuid).execute(db.pool()).await?;
println!(" dev.videos: removed");
// Delete output files
for entry in fs::read_dir(OUTPUT_DIR)? {
let entry = entry?;
let fname = entry.file_name().to_string_lossy().to_string();
if fname.starts_with(uuid) {
fs::remove_file(entry.path())?;
}
}
println!(" Output files: removed");
// Delete video file
if !file_path.is_empty() {
let vp = Path::new(file_path);
if vp.exists() {
fs::remove_file(vp)?;
println!(" Video file: removed ({})", vp.file_name().unwrap().to_str().unwrap_or("?"));
}
}
// Delete release directory
let release_path = Path::new(RELEASE_DIR).join(uuid);
if release_path.exists() {
fs::remove_dir_all(&release_path)?;
println!(" Release dir: removed");
}
println!("\n=== Undeploy Complete ===");
Ok(())
}
// ---- List ----
async fn cmd_list(db: &PostgresDb) -> Result<()> {
let rows = sqlx::query(
"SELECT file_uuid, file_name, duration, status,
(SELECT COUNT(*) FROM dev.chunk WHERE file_uuid = v.file_uuid) as chunks,
(SELECT COUNT(*) FROM dev.face_detections WHERE file_uuid = v.file_uuid) as faces
FROM dev.videos v ORDER BY id DESC"
).fetch_all(db.pool()).await?;
println!("{:<36} {:<44} {:>8} {:>10} {:>6} {:>6}",
"UUID", "Name", "Duration", "Status", "Chunks", "Faces");
println!("{}", "-".repeat(116));
for row in &rows {
let uuid: String = row.get(0);
let name: String = row.get::<Option<String>, _>(1).unwrap_or_default();
let duration: Option<f64> = row.get(2);
let status: Option<String> = row.get(3);
let chunks: Option<i64> = row.get(4);
let faces: Option<i64> = row.get(5);
let dur_str = match duration {
Some(d) if d > 60.0 => format!("{:5.0}min", d / 60.0),
Some(d) => format!("{:5.0}s", d),
None => "?".to_string(),
};
let short_name = if name.chars().count() > 42 {
format!("{}..", name.chars().take(40).collect::<String>())
} else {
name.clone()
};
println!("{:<36} {:<44} {:>8} {:>10} {:>6} {:>6}",
uuid, short_name, dur_str,
status.as_deref().unwrap_or("?"),
chunks.unwrap_or(0), faces.unwrap_or(0));
}
Ok(())
}
// ---- Package ----
async fn cmd_package(db: &PostgresDb, uuid: &str) -> Result<()> {
println!("=== Package: {} ===", uuid);
// Verify video exists
let row = sqlx::query(
"SELECT file_uuid, file_name, file_path, duration, fps, width, height FROM dev.videos WHERE file_uuid = $1"
).bind(uuid).fetch_optional(db.pool()).await?;
let (_, file_name, file_path, duration, fps, width, height): (
String, String, String, Option<f64>, Option<f64>, Option<i32>, Option<i32>
) = match row {
Some(r) => (r.get(0), r.get(1), r.get(2), r.get(3), r.get(4), r.get(5), r.get(6)),
None => anyhow::bail!("UUID {} not found", uuid),
};
let outdir = Path::new(RELEASE_DIR).join(uuid);
if outdir.exists() {
fs::remove_dir_all(&outdir)?;
}
fs::create_dir_all(&outdir)?;
// Write file_info.json
let info = serde_json::json!({
"file_uuid": uuid,
"file_name": file_name,
"duration": duration,
"fps": fps,
"width": width,
"height": height,
"status": "completed",
});
fs::write(outdir.join("file_info.json"), serde_json::to_string_pretty(&info)?)?;
// Export data.sql
let sql_path = outdir.join("data.sql");
let tables = [
("dev.videos", "file_uuid"),
("dev.chunk", "file_uuid"),
("dev.chunk_vectors", "uuid"),
("dev.face_detections", "file_uuid"),
];
{
let mut f = fs::File::create(&sql_path)?;
writeln!(f, "-- Release package: {}", uuid)?;
writeln!(f, "BEGIN;")?;
writeln!(f)?;
for (tbl, col) in &tables {
writeln!(f, "-- {} WHERE {} = '{}'", tbl, col, uuid)?;
// Get columns
let parts: Vec<&str> = tbl.split('.').collect();
let cols = psql_exec(&format!(
"SELECT string_agg(column_name, ', ' ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='{}' AND table_name='{}' AND is_updatable='YES'",
parts[0], parts[1]
))?;
// COPY
let data = psql_exec(&format!(
"COPY (SELECT * FROM {} WHERE {} = '{}') TO STDOUT WITH CSV HEADER",
tbl, col, uuid
))?;
if !data.is_empty() {
writeln!(f, "COPY {} ({}) FROM STDIN WITH CSV HEADER;", tbl, cols)?;
writeln!(f, "{}", data)?;
writeln!(f, "\\.")?;
writeln!(f)?;
}
}
// Export identities referenced by this file
writeln!(f, "-- dev.identities (referenced by face_detections)")?;
let cols = psql_exec("SELECT string_agg(column_name, ', ' ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='dev' AND table_name='identities' AND is_updatable='YES'")?;
let data = psql_exec(&format!(
"COPY (SELECT DISTINCT i.* FROM dev.identities i INNER JOIN dev.face_detections fd ON fd.identity_id = i.id WHERE fd.file_uuid = '{}') TO STDOUT WITH CSV HEADER", uuid
))?;
if !data.is_empty() {
writeln!(f, "COPY dev.identities ({}) FROM STDIN WITH CSV HEADER;", cols)?;
writeln!(f, "{}", data)?;
writeln!(f, "\\.")?;
writeln!(f)?;
}
// Export identity_bindings for identities referenced by this file
writeln!(f, "-- dev.identity_bindings (for identities in face_detections)")?;
let cols = psql_exec("SELECT string_agg(column_name, ', ' ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='dev' AND table_name='identity_bindings' AND is_updatable='YES'")?;
let data = psql_exec(&format!(
"COPY (SELECT DISTINCT ib.* FROM dev.identity_bindings ib INNER JOIN dev.face_detections fd ON fd.identity_id = ib.identity_id WHERE fd.file_uuid = '{}') TO STDOUT WITH CSV HEADER", uuid
))?;
if !data.is_empty() {
writeln!(f, "COPY dev.identity_bindings ({}) FROM STDIN WITH CSV HEADER;", cols)?;
writeln!(f, "{}", data)?;
writeln!(f, "\\.")?;
writeln!(f)?;
}
writeln!(f, "COMMIT;")?;
}
let sql_size = fs::metadata(&sql_path)?.len();
println!(" data.sql ({} MB)", sql_size / 1024 / 1024);
// Copy video file
if !file_path.is_empty() {
let vp = Path::new(&file_path);
if vp.exists() {
let dest = outdir.join(vp.file_name().unwrap());
fs::copy(vp, &dest)?;
let vsize = fs::metadata(&dest)?.len();
println!(" {} ({} MB)", vp.file_name().unwrap().to_str().unwrap_or("?"), vsize / 1024 / 1024);
}
}
// Generate identities.json for offline analysis
let id_script = "/Users/accusys/momentry_core_0.1/scripts/export_identities.py";
let id_out = format!("{}/{}.identities.json", OUTPUT_DIR, uuid);
let _ = Command::new("/opt/homebrew/bin/python3.11")
.args([id_script, uuid, &id_out])
.status();
if Path::new(&id_out).exists() {
println!(" Identities JSON generated");
}
// Generate SQLite database for offline app use
let sqlite_script = "/Users/accusys/momentry_core_0.1/scripts/export_sqlite.py";
let sqlite_out = format!("{}/{}.sqlite", OUTPUT_DIR, uuid);
let _ = Command::new("/opt/homebrew/bin/python3.11")
.args([sqlite_script, uuid, &sqlite_out])
.status();
if Path::new(&sqlite_out).exists() {
let sz = fs::metadata(&sqlite_out)?.len();
println!(" SQLite database: {}MB", sz / 1048576);
}
// Copy output files (JSONs + SQLite + any data files)
for entry in fs::read_dir(OUTPUT_DIR)? {
let entry = entry?;
let fname = entry.file_name().to_string_lossy().to_string();
if fname.starts_with(uuid) {
fs::copy(entry.path(), outdir.join(&fname))?;
}
}
println!(" Output files copied");
// Copy deploy + verify scripts into package
let deploy_src = "/Users/accusys/momentry_core_0.1/scripts/deploy_package.sh";
let verify_src = "/Users/accusys/momentry_core_0.1/scripts/verify_package.sh";
if Path::new(deploy_src).exists() {
fs::copy(deploy_src, outdir.join("deploy.sh"))?;
}
if Path::new(verify_src).exists() {
fs::copy(verify_src, outdir.join("verify.sh"))?;
}
// Create tar.gz
let tarball = Path::new(RELEASE_DIR).join(format!("{}_v{}.tar.gz", uuid, Utc::now().format("%Y%m%d_%H%M%S")));
let status = Command::new("tar")
.args(["-czf", tarball.to_str().unwrap(), "-C", RELEASE_DIR, uuid])
.status()?;
if !status.success() {
anyhow::bail!("tar creation failed");
}
let tsize = fs::metadata(&tarball)?.len();
println!("\n Package: {} ({} MB)", tarball.display(), tsize / 1024 / 1024);
Ok(())
}
fn cmd_visualize_offline(sqlite_path: &str, output: Option<&str>, identity: Option<i64>) -> Result<()> {
let outpath = match output {
Some(p) => p.to_string(),
None => sqlite_path.replace(".sqlite", "_report.html"),
};
let script = "/Users/accusys/momentry_core_0.1/scripts/render_offline_report.py";
let mut args: Vec<String> = vec![script.to_string(), sqlite_path.to_string(), outpath.clone()];
if let Some(id) = identity {
args.push("--identity".to_string());
args.push(id.to_string());
}
let output = Command::new("/opt/homebrew/bin/python3.11")
.args(&args)
.output()
.context("Offline report script failed")?;
if !output.status.success() {
anyhow::bail!("Offline report: {}", String::from_utf8_lossy(&output.stderr));
}
println!("{}", String::from_utf8_lossy(&output.stdout));
println!("\n Open: {}", outpath);
Ok(())
}
// ---- Visualize ----
fn cmd_visualize(uuid: &str, typ: &str, output: Option<&str>, identity: Option<i64>) -> Result<()> {
let outpath = match output {
Some(p) => p.to_string(),
None => format!("/Users/accusys/momentry/output_dev/{}_heatmap.html", uuid),
};
match typ {
"heatmap" | "density" => generate_face_heatmap(uuid, &outpath, identity)?,
"timeline" => generate_face_timeline(uuid, &outpath, identity)?,
_ => anyhow::bail!("Unknown visualization type: {}. Try: heatmap, density, timeline", typ),
}
Ok(())
}
fn generate_face_heatmap(uuid: &str, outpath: &str, identity: Option<i64>) -> Result<()> {
let script = "/Users/accusys/momentry_core_0.1/scripts/render_face_heatmap.py";
let mut args: Vec<String> = vec![script.to_string(), uuid.to_string(), outpath.to_string()];
if let Some(id) = identity {
args.push("--identity".to_string());
args.push(id.to_string());
}
let output = Command::new("/opt/homebrew/bin/python3.11")
.args(&args)
.output()
.context("Python heatmap script failed")?;
if !output.status.success() {
anyhow::bail!("Heatmap: {}", String::from_utf8_lossy(&output.stderr));
}
println!("{}", String::from_utf8_lossy(&output.stdout));
println!("\n Open: {}", outpath);
Ok(())
}
fn generate_face_timeline(uuid: &str, outpath: &str, identity: Option<i64>) -> Result<()> {
generate_face_heatmap(uuid, outpath, identity)
}
// ---- Stats ----
fn cmd_stats() -> Result<()> {
let pkg_dir = Path::new(RELEASE_DIR);
if !pkg_dir.exists() {
println!("No release packages found at {}", pkg_dir.display());
return Ok(());
}
let mut packages: Vec<PathBuf> = Vec::new();
for entry in fs::read_dir(&pkg_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if name.ends_with(".tar.gz") {
packages.push(entry.path());
}
}
packages.sort_by(|a, b| b.cmp(a)); // newest first
if packages.is_empty() {
println!("No .tar.gz packages found.");
return Ok(());
}
for pkg_path in &packages {
let pkg_name = pkg_path.file_name().unwrap().to_str().unwrap_or("?");
let pkg_size = fs::metadata(pkg_path)?.len();
println!("📦 {} ({} MB)", pkg_name, pkg_size / 1024 / 1024);
// List contents via tar -tvzf (shows sizes without extraction)
let output = Command::new("tar")
.args(["-tvzf", pkg_path.to_str().unwrap()])
.output()
.context("tar list failed")?;
let listing = String::from_utf8_lossy(&output.stdout);
let mut total_sql = 0u64;
let mut total_video = 0u64;
let mut total_json = 0u64;
let mut sql_count = 0u64;
let mut video_count = 0u64;
let mut json_count = 0u64;
for line in listing.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.ends_with('/') { continue; }
// tar -tvzf format: perms link owner group size date_month date_day time path...
// Fields are space-separated; size is 5th field, path starts at 8th field
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() < 8 { continue; }
let fsize = parts[4].parse::<u64>().unwrap_or(0);
let fpath = parts[8..].join(" ");
let fname = Path::new(&fpath).file_name().unwrap_or_default().to_str().unwrap_or("?");
let ext = Path::new(&fpath).extension().unwrap_or_default().to_str().unwrap_or("");
match ext {
"sql" => {
println!(" 📄 {} ({:.0} MB)", fname, fsize as f64 / 1048576.0);
total_sql += fsize;
sql_count += 1;
}
"mp4" | "mov" | "avi" | "mkv" => {
println!(" 🎬 {} ({:.0} MB)", fname, fsize as f64 / 1048576.0);
total_video += fsize;
video_count += 1;
}
"json" => {
if fname != "file_info.json" {
println!(" 📋 {} ({:.0} MB)", fname, fsize as f64 / 1048576.0);
}
total_json += fsize;
json_count += 1;
}
_ => {}
}
}
println!(" ─────────────────────────────");
println!(" SQL: {} files, {:.0} MB", sql_count, total_sql as f64 / 1048576.0);
println!(" Video: {} files, {:.0} MB", video_count, total_video as f64 / 1048576.0);
println!(" JSON: {} files, {:.0} MB", json_count, total_json as f64 / 1048576.0);
println!(" Total: {:.0} MB (compressed: {:.0} MB)", (total_sql + total_video + total_json) as f64 / 1048576.0, pkg_size as f64 / 1048576.0);
println!();
}
Ok(())
}
// ---- Main ----
#[tokio::main]
async fn main() -> Result<()> {
dotenv::from_filename(".env.development").ok();
let cli = Cli::parse();
let db = PostgresDb::new(&config::DATABASE_URL).await?;
match cli.command {
Commands::Deploy { tarball } => cmd_deploy(&db, &tarball).await?,
Commands::Undeploy { uuid, yes } => cmd_undeploy(&db, &uuid, yes).await?,
Commands::List => cmd_list(&db).await?,
Commands::Package { uuid } => cmd_package(&db, &uuid).await?,
Commands::Stats => cmd_stats()?,
Commands::Visualize { uuid, typ, output, identity } => cmd_visualize(&uuid, &typ, output.as_deref(), identity)?,
Commands::VisualizeOffline { sqlite_path, output, identity } => cmd_visualize_offline(&sqlite_path, output.as_deref(), identity)?,
}
Ok(())
}