fix(aw-db): add guarded sqlite vacuum maintenance
This commit is contained in:
+35
-1
@@ -22,7 +22,9 @@ scripts with durable standalone Rust modules.
|
||||
- `detmir-dlp` - SSH wrapper replacement for remote DLP health JSON collection.
|
||||
- `dlp-health-check` - AW server DLP health check replacement.
|
||||
- `aw-db-maintenance` - guarded weekly SQLite maintenance for old allowlisted
|
||||
process-level session events, with backup-before-delete.
|
||||
process-level session events, with backup-before-delete; nightly SQLite
|
||||
compaction is handled by the same binary in `--vacuum` mode and scheduled
|
||||
separately from the trim job.
|
||||
- `aw-ensure-reliability` - safe dry-run/apply planner for AW service
|
||||
reliability repair actions that were previously immediate Bash mutations.
|
||||
- `aw-linux-install` - safe dry-run/apply planner for Linux ActivityWatch
|
||||
@@ -58,6 +60,38 @@ scripts with durable standalone Rust modules.
|
||||
- `detmir-status` - read-only DetMir state normalizer with text, JSON, and ADK
|
||||
`Content` output. Also builds `detmir-adk-status` as a compatibility binary.
|
||||
|
||||
## SQLite Maintenance Safety
|
||||
|
||||
`aw-db-maintenance` has two separate modes:
|
||||
|
||||
- default trim mode removes only old allowlisted `process_start` /
|
||||
`process_stop` rows from the configured session bucket and is dry-run unless
|
||||
`--apply` is passed;
|
||||
- `--vacuum` compacts the SQLite DB with `VACUUM INTO`, checks
|
||||
`PRAGMA integrity_check`, preserves owner/mode, and replaces the DB only after
|
||||
backup and integrity success.
|
||||
|
||||
Both apply modes use `AW_DB_MAINTENANCE_LOCK_PATH` /
|
||||
`--lock-path` to block concurrent trim/VACUUM runs. VACUUM also checks the
|
||||
configured `activitywatch-server.service` through systemd, refuses unknown or
|
||||
failed unit states, stops the service before compaction, and starts it again
|
||||
through a guard on success or error.
|
||||
|
||||
Do not run VACUUM during business hours, active incident response, evidence
|
||||
collection, active backup/restore, or when the ActivityWatch service/unit state
|
||||
is unclear. Rollback is replacing the SQLite DB from
|
||||
`/var/lib/activitywatch/backups/db/aw-sqlite-before-db-vacuum-*.db` while
|
||||
`activitywatch-server.service` is stopped, then starting the service and
|
||||
checking `aw-db-health`/`detmir-status`.
|
||||
|
||||
The Ansible deploy installs the VACUUM unit files but does not enable the
|
||||
nightly timer unless `aw_db_vacuum_timer_enabled=true` is set explicitly.
|
||||
Disable it with:
|
||||
|
||||
```bash
|
||||
systemctl disable --now aw-db-vacuum.timer
|
||||
```
|
||||
|
||||
## Migration Runbook
|
||||
|
||||
Use `RUNBOOK.md` as the operational plan for replacing Python and shell modules
|
||||
|
||||
@@ -1118,6 +1118,45 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg
|
||||
(`sqlite.db=359.6MiB`, WAL `4.0MiB`, session rows `174`, recent process
|
||||
events `0`, latest `eventType=logon`), DetMir status OK with
|
||||
`dlp_counts={ok:22,warn:0,fail:0}`, `ok_for_operator=true`.
|
||||
35.2. `[done]` Закрепить nightly VACUUM для AW SQLite:
|
||||
- `aw-db-maintenance` получил отдельный `--vacuum` режим; weekly trim
|
||||
остается отдельной задачей и не смешивается с compaction;
|
||||
- добавлены `aw-server/aw-db-vacuum.service` и
|
||||
`aw-server/aw-db-vacuum.timer`;
|
||||
- timer schedule: `OnCalendar=*-*-* 02:10:00`,
|
||||
`RandomizedDelaySec=10m`, `Persistent=true`; Ansible не включает timer
|
||||
без явного opt-in `aw_db_vacuum_timer_enabled=true`;
|
||||
- если opt-in не задан, `deploy_aw_server.yml` оставляет unit-файлы на
|
||||
сервере, но держит `aw-db-vacuum.timer` в `disabled/stopped`;
|
||||
- nightly job перед VACUUM останавливает `activitywatch-server.service`,
|
||||
делает rollback backup SQLite DB, выполняет `VACUUM INTO`, проверяет
|
||||
`PRAGMA integrity_check`, сохраняет владельца и режим файла и
|
||||
поднимает server обратно;
|
||||
- apply-режимы weekly trim и nightly VACUUM используют общий lock
|
||||
`/run/aw-db-maintenance.lock`, поэтому одновременный запуск завершается
|
||||
fail-closed без записи в SQLite;
|
||||
- VACUUM нельзя запускать в рабочее время, во время активного
|
||||
расследования/снятия доказательств, backup/restore, неизвестного
|
||||
состояния `activitywatch-server.service` или если уже есть maintenance
|
||||
lock;
|
||||
- dry-run проверки:
|
||||
`aw-db-maintenance --vacuum --json` и обычный
|
||||
`aw-db-maintenance --json`; они не создают backup и не пишут в DB;
|
||||
- включить timer явно:
|
||||
`ansible-playbook -i inventory.ini deploy_aw_server.yml -e aw_db_vacuum_timer_enabled=true`;
|
||||
- отключить timer:
|
||||
`systemctl disable --now aw-db-vacuum.timer`;
|
||||
- rollback: остановить `activitywatch-server.service`, заменить
|
||||
`/var/lib/activitywatch/aw-server-rust/sqlite.db` из последнего
|
||||
`/var/lib/activitywatch/backups/db/aw-sqlite-before-db-vacuum-*.db`,
|
||||
вернуть владельца/режим, запустить service, проверить `aw-db-health` и
|
||||
`detmir-status`;
|
||||
- local gates: `cargo fmt --all -- --check`, `cargo test --workspace`,
|
||||
`cargo clippy --workspace --all-targets -- -D warnings`,
|
||||
`cargo build --workspace --release`, systemd unit verify,
|
||||
`ansible-playbook -i inventory.ini deploy_aw_server.yml --syntax-check`,
|
||||
`scripts/check_detmir_rust_release_artifacts.sh`,
|
||||
`scripts/quality-gate.sh`;
|
||||
36. `[done]` Устранить blocker полного AW server deploy на Influx token:
|
||||
- проблема: `deploy_aw_server.yml` падал на assert
|
||||
`aw_worktime_influx_enabled=true`, потому что локальные env
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::fs;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
@@ -12,6 +14,8 @@ use serde_json::Value;
|
||||
const DEFAULT_DB_PATH: &str = "/var/lib/activitywatch/aw-server-rust/sqlite.db";
|
||||
const DEFAULT_BACKUP_DIR: &str = "/var/lib/activitywatch/backups/db";
|
||||
const DEFAULT_HOST: &str = "HOST-EXAMPLE";
|
||||
const DEFAULT_SERVICE_UNIT: &str = "activitywatch-server.service";
|
||||
const DEFAULT_LOCK_PATH: &str = "/run/aw-db-maintenance.lock";
|
||||
const ALLOWED_EVENT_TYPES: &[&str] = &["process_start", "process_stop"];
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
@@ -38,6 +42,19 @@ struct Cli {
|
||||
#[arg(long)]
|
||||
apply: bool,
|
||||
|
||||
#[arg(long)]
|
||||
vacuum: bool,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value = DEFAULT_SERVICE_UNIT,
|
||||
env = "AW_DB_MAINTENANCE_SERVICE_UNIT"
|
||||
)]
|
||||
service_unit: String,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_LOCK_PATH, env = "AW_DB_MAINTENANCE_LOCK_PATH")]
|
||||
lock_path: PathBuf,
|
||||
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
}
|
||||
@@ -56,9 +73,52 @@ struct Report {
|
||||
planned_delete_rows: usize,
|
||||
deleted_rows: usize,
|
||||
backup_created: bool,
|
||||
lock_path: String,
|
||||
skipped_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct VacuumReport {
|
||||
apply: bool,
|
||||
generated_at_utc: String,
|
||||
db_path: String,
|
||||
service_unit: String,
|
||||
service_was_active: bool,
|
||||
service_restarted: bool,
|
||||
backup_path: Option<String>,
|
||||
backup_created: bool,
|
||||
lock_path: String,
|
||||
db_size_before_bytes: Option<u64>,
|
||||
vacuumed_path: Option<String>,
|
||||
vacuumed_size_bytes: Option<u64>,
|
||||
integrity_check: Option<String>,
|
||||
replaced_db: bool,
|
||||
skipped_reason: Option<String>,
|
||||
}
|
||||
|
||||
struct VacuumResult {
|
||||
backup_path: PathBuf,
|
||||
vacuumed_path: PathBuf,
|
||||
db_size_before_bytes: u64,
|
||||
vacuumed_size_bytes: u64,
|
||||
integrity_check: String,
|
||||
}
|
||||
|
||||
struct ServiceGuard {
|
||||
unit: String,
|
||||
was_active: bool,
|
||||
restored: bool,
|
||||
}
|
||||
|
||||
struct TempFileGuard {
|
||||
path: PathBuf,
|
||||
keep: bool,
|
||||
}
|
||||
|
||||
struct LockFileGuard {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let code = match run() {
|
||||
Ok(code) => code,
|
||||
@@ -72,11 +132,20 @@ fn main() {
|
||||
|
||||
fn run() -> Result<i32> {
|
||||
let cli = Cli::parse();
|
||||
let report = build_report(&cli)?;
|
||||
if cli.json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
if cli.vacuum {
|
||||
let report = build_vacuum_report(&cli)?;
|
||||
if cli.json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else {
|
||||
print_vacuum_text(&report);
|
||||
}
|
||||
} else {
|
||||
print_text(&report);
|
||||
let report = build_report(&cli)?;
|
||||
if cli.json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else {
|
||||
print_text(&report);
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
@@ -116,10 +185,15 @@ fn build_report(cli: &Cli) -> Result<Report> {
|
||||
let mut backup_file = None;
|
||||
let mut backup_created = false;
|
||||
let mut deleted = 0;
|
||||
let _lock_guard = if cli.apply && planned > 0 {
|
||||
Some(LockFileGuard::acquire(&cli.lock_path)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if cli.apply && planned > 0 {
|
||||
fs::create_dir_all(&cli.backup_dir)
|
||||
.with_context(|| format!("create backup dir {}", cli.backup_dir.display()))?;
|
||||
let backup = backup_path(&cli.backup_dir);
|
||||
let backup = backup_path(&cli.backup_dir, "aw-sqlite-before-db-maintenance");
|
||||
copy_sqlite_via_backup(&cli.db_path, &backup)?;
|
||||
backup_file = Some(backup);
|
||||
backup_created = true;
|
||||
@@ -139,6 +213,60 @@ fn build_report(cli: &Cli) -> Result<Report> {
|
||||
))
|
||||
}
|
||||
|
||||
fn build_vacuum_report(cli: &Cli) -> Result<VacuumReport> {
|
||||
if !cli.db_path.exists() {
|
||||
return Ok(vacuum_report(
|
||||
cli,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("database not found".to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
if !cli.apply {
|
||||
return Ok(vacuum_report(
|
||||
cli,
|
||||
false,
|
||||
Some(file_size(&cli.db_path)?),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("dry-run".to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
let _lock_guard = LockFileGuard::acquire(&cli.lock_path)?;
|
||||
let mut service_guard = ServiceGuard::stop_if_active(&cli.service_unit)?;
|
||||
let service_was_active = service_guard.was_active;
|
||||
let result = vacuum_sqlite_db(&cli.db_path, &cli.backup_dir)?;
|
||||
let service_restarted = service_guard.restore()?;
|
||||
|
||||
Ok(vacuum_report(
|
||||
cli,
|
||||
true,
|
||||
Some(result.db_size_before_bytes),
|
||||
true,
|
||||
service_was_active,
|
||||
service_restarted,
|
||||
Some(result.backup_path),
|
||||
Some(result.vacuumed_path),
|
||||
Some(result.vacuumed_size_bytes),
|
||||
Some(result.integrity_check),
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn base_report(
|
||||
cli: &Cli,
|
||||
@@ -164,6 +292,40 @@ fn base_report(
|
||||
planned_delete_rows,
|
||||
deleted_rows,
|
||||
backup_created,
|
||||
lock_path: cli.lock_path.display().to_string(),
|
||||
skipped_reason,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn vacuum_report(
|
||||
cli: &Cli,
|
||||
apply: bool,
|
||||
db_size_before_bytes: Option<u64>,
|
||||
backup_created: bool,
|
||||
service_was_active: bool,
|
||||
service_restarted: bool,
|
||||
backup_path: Option<PathBuf>,
|
||||
vacuumed_path: Option<PathBuf>,
|
||||
vacuumed_size_bytes: Option<u64>,
|
||||
integrity_check: Option<String>,
|
||||
skipped_reason: Option<String>,
|
||||
) -> VacuumReport {
|
||||
VacuumReport {
|
||||
apply,
|
||||
generated_at_utc: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
db_path: cli.db_path.display().to_string(),
|
||||
service_unit: cli.service_unit.clone(),
|
||||
service_was_active,
|
||||
service_restarted,
|
||||
backup_path: backup_path.map(|path| path.display().to_string()),
|
||||
backup_created,
|
||||
lock_path: cli.lock_path.display().to_string(),
|
||||
db_size_before_bytes,
|
||||
vacuumed_path: vacuumed_path.map(|path| path.display().to_string()),
|
||||
vacuumed_size_bytes,
|
||||
integrity_check,
|
||||
replaced_db: apply && skipped_reason.is_none(),
|
||||
skipped_reason,
|
||||
}
|
||||
}
|
||||
@@ -237,13 +399,256 @@ fn delete_events(conn: &Connection, ids: &[i64], chunk_size: usize) -> Result<us
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
fn backup_path(backup_dir: &Path) -> PathBuf {
|
||||
fn backup_path(backup_dir: &Path, prefix: &str) -> PathBuf {
|
||||
backup_dir.join(format!(
|
||||
"aw-sqlite-before-db-maintenance-{}.db",
|
||||
"{}-{}.db",
|
||||
prefix,
|
||||
Utc::now().format("%Y%m%dT%H%M%SZ")
|
||||
))
|
||||
}
|
||||
|
||||
fn vacuum_sqlite_db(db_path: &Path, backup_dir: &Path) -> Result<VacuumResult> {
|
||||
fs::create_dir_all(backup_dir)
|
||||
.with_context(|| format!("create backup dir {}", backup_dir.display()))?;
|
||||
let db_size_before_bytes = file_size(db_path)?;
|
||||
let backup_path = backup_path(backup_dir, "aw-sqlite-before-db-vacuum");
|
||||
copy_sqlite_via_backup(db_path, &backup_path)?;
|
||||
let vacuumed_path = vacuumed_path(db_path)?;
|
||||
let mut vacuum_cleanup = TempFileGuard::new(vacuumed_path.clone());
|
||||
vacuum_into(db_path, &vacuumed_path)?;
|
||||
preserve_sqlite_metadata(db_path, &vacuumed_path)?;
|
||||
let vacuumed_size_bytes = file_size(&vacuumed_path)?;
|
||||
let integrity_check = integrity_check(&vacuumed_path)?;
|
||||
remove_sqlite_sidecars(db_path)?;
|
||||
fs::rename(&vacuumed_path, db_path).with_context(|| {
|
||||
format!(
|
||||
"replace {} with {}",
|
||||
db_path.display(),
|
||||
vacuumed_path.display()
|
||||
)
|
||||
})?;
|
||||
vacuum_cleanup.disarm();
|
||||
Ok(VacuumResult {
|
||||
backup_path,
|
||||
vacuumed_path,
|
||||
db_size_before_bytes,
|
||||
vacuumed_size_bytes,
|
||||
integrity_check,
|
||||
})
|
||||
}
|
||||
|
||||
fn vacuum_into(src: &Path, dst: &Path) -> Result<()> {
|
||||
let conn = open_connection(src, true)?;
|
||||
let sql = format!("VACUUM INTO {}", sqlite_string_literal(dst));
|
||||
conn.execute_batch(&sql)
|
||||
.with_context(|| format!("VACUUM INTO {}", dst.display()))
|
||||
}
|
||||
|
||||
fn integrity_check(path: &Path) -> Result<String> {
|
||||
let conn = open_connection(path, false)?;
|
||||
let result: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
|
||||
if result != "ok" {
|
||||
bail!("integrity_check failed for {}: {result}", path.display());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn remove_sqlite_sidecars(db_path: &Path) -> Result<()> {
|
||||
for suffix in ["-wal", "-shm", "-journal"] {
|
||||
let sidecar = sqlite_sidecar_path(db_path, suffix)?;
|
||||
match fs::remove_file(&sidecar) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(err).with_context(|| format!("remove {}", sidecar.display())),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sqlite_sidecar_path(db_path: &Path, suffix: &str) -> Result<PathBuf> {
|
||||
let file_name = db_path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.context("database path must have a file name")?;
|
||||
Ok(db_path.with_file_name(format!("{file_name}{suffix}")))
|
||||
}
|
||||
|
||||
fn vacuumed_path(db_path: &Path) -> Result<PathBuf> {
|
||||
let file_name = db_path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.context("database path must have a file name")?;
|
||||
Ok(db_path.with_file_name(format!(
|
||||
"{file_name}.vacuumed-{}",
|
||||
Utc::now().format("%Y%m%dT%H%M%SZ")
|
||||
)))
|
||||
}
|
||||
|
||||
fn file_size(path: &Path) -> Result<u64> {
|
||||
Ok(fs::metadata(path)
|
||||
.with_context(|| format!("stat {}", path.display()))?
|
||||
.len())
|
||||
}
|
||||
|
||||
fn sqlite_string_literal(path: &Path) -> String {
|
||||
format!("'{}'", path.display().to_string().replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn preserve_sqlite_metadata(src: &Path, dst: &Path) -> Result<()> {
|
||||
let metadata = fs::metadata(src).with_context(|| format!("stat {}", src.display()))?;
|
||||
let permissions = metadata.permissions();
|
||||
fs::set_permissions(dst, permissions)
|
||||
.with_context(|| format!("preserve permissions for {}", dst.display()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let dst_metadata = fs::metadata(dst).with_context(|| format!("stat {}", dst.display()))?;
|
||||
if dst_metadata.uid() != metadata.uid() || dst_metadata.gid() != metadata.gid() {
|
||||
let status = Command::new("chown")
|
||||
.arg(format!("{}:{}", metadata.uid(), metadata.gid()))
|
||||
.arg(dst)
|
||||
.status()
|
||||
.context("run chown for vacuumed SQLite DB")?;
|
||||
if !status.success() {
|
||||
bail!("chown failed for {}", dst.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn systemctl_is_active(unit: &str) -> Result<bool> {
|
||||
let load_state = systemctl_load_state(unit)?;
|
||||
if load_state != "loaded" {
|
||||
bail!("refusing SQLite VACUUM because systemd unit {unit} load_state={load_state:?}");
|
||||
}
|
||||
let output = Command::new("systemctl")
|
||||
.args(["is-active", unit])
|
||||
.output()
|
||||
.with_context(|| format!("systemctl is-active {unit}"))?;
|
||||
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if output.status.success() && state == "active" {
|
||||
return Ok(true);
|
||||
}
|
||||
if output.status.code() == Some(3) && state == "inactive" {
|
||||
return Ok(false);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
bail!(
|
||||
"refusing SQLite VACUUM because systemctl is-active {unit} returned state={state:?}, status={}, stderr={stderr:?}",
|
||||
output.status
|
||||
);
|
||||
}
|
||||
|
||||
fn systemctl_load_state(unit: &str) -> Result<String> {
|
||||
let output = Command::new("systemctl")
|
||||
.args(["show", "-p", "LoadState", "--value", unit])
|
||||
.output()
|
||||
.with_context(|| format!("systemctl show LoadState {unit}"))?;
|
||||
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if output.status.success() && !state.is_empty() {
|
||||
return Ok(state);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
bail!(
|
||||
"refusing SQLite VACUUM because systemctl show LoadState {unit} failed with status={}, stderr={stderr:?}",
|
||||
output.status
|
||||
);
|
||||
}
|
||||
|
||||
fn systemctl_action(action: &str, unit: &str) -> Result<()> {
|
||||
let status = Command::new("systemctl")
|
||||
.args([action, unit])
|
||||
.status()
|
||||
.with_context(|| format!("systemctl {action} {unit}"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("systemctl {action} {unit} failed with status {status}");
|
||||
}
|
||||
}
|
||||
|
||||
impl ServiceGuard {
|
||||
fn stop_if_active(unit: &str) -> Result<Self> {
|
||||
let was_active = systemctl_is_active(unit)?;
|
||||
if was_active {
|
||||
systemctl_action("stop", unit)?;
|
||||
}
|
||||
Ok(Self {
|
||||
unit: unit.to_string(),
|
||||
was_active,
|
||||
restored: !was_active,
|
||||
})
|
||||
}
|
||||
|
||||
fn restore(&mut self) -> Result<bool> {
|
||||
if self.was_active && !self.restored {
|
||||
systemctl_action("start", &self.unit)?;
|
||||
self.restored = true;
|
||||
}
|
||||
Ok(self.was_active)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ServiceGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.was_active && !self.restored {
|
||||
let _ = systemctl_action("start", &self.unit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TempFileGuard {
|
||||
fn new(path: PathBuf) -> Self {
|
||||
Self { path, keep: false }
|
||||
}
|
||||
|
||||
fn disarm(&mut self) {
|
||||
self.keep = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempFileGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.keep {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LockFileGuard {
|
||||
fn acquire(path: &Path) -> Result<Self> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create lock parent {}", parent.display()))?;
|
||||
}
|
||||
let mut file = match OpenOptions::new().write(true).create_new(true).open(path) {
|
||||
Ok(file) => file,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
bail!("maintenance lock already exists: {}", path.display());
|
||||
}
|
||||
Err(err) => return Err(err).with_context(|| format!("create lock {}", path.display())),
|
||||
};
|
||||
writeln!(
|
||||
file,
|
||||
"pid={} generated_at_utc={}",
|
||||
std::process::id(),
|
||||
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||
)
|
||||
.with_context(|| format!("write lock {}", path.display()))?;
|
||||
Ok(Self {
|
||||
path: path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LockFileGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_text(report: &Report) {
|
||||
println!(
|
||||
"aw-db-maintenance: {}",
|
||||
@@ -255,6 +660,7 @@ fn print_text(report: &Report) {
|
||||
println!("planned_delete_rows: {}", report.planned_delete_rows);
|
||||
println!("deleted_rows: {}", report.deleted_rows);
|
||||
println!("backup_created: {}", report.backup_created);
|
||||
println!("lock_path: {}", report.lock_path);
|
||||
if let Some(path) = &report.backup_path {
|
||||
println!("backup_path: {path}");
|
||||
}
|
||||
@@ -263,6 +669,38 @@ fn print_text(report: &Report) {
|
||||
}
|
||||
}
|
||||
|
||||
fn print_vacuum_text(report: &VacuumReport) {
|
||||
println!(
|
||||
"aw-db-vacuum: {}",
|
||||
if report.apply { "apply" } else { "dry-run" }
|
||||
);
|
||||
println!("db_path: {}", report.db_path);
|
||||
println!("service_unit: {}", report.service_unit);
|
||||
println!("service_was_active: {}", report.service_was_active);
|
||||
println!("service_restarted: {}", report.service_restarted);
|
||||
println!("backup_created: {}", report.backup_created);
|
||||
println!("lock_path: {}", report.lock_path);
|
||||
if let Some(path) = &report.backup_path {
|
||||
println!("backup_path: {path}");
|
||||
}
|
||||
if let Some(size) = report.db_size_before_bytes {
|
||||
println!("db_size_before_bytes: {size}");
|
||||
}
|
||||
if let Some(path) = &report.vacuumed_path {
|
||||
println!("vacuumed_path: {path}");
|
||||
}
|
||||
if let Some(size) = report.vacuumed_size_bytes {
|
||||
println!("vacuumed_size_bytes: {size}");
|
||||
}
|
||||
if let Some(check) = &report.integrity_check {
|
||||
println!("integrity_check: {check}");
|
||||
}
|
||||
println!("replaced_db: {}", report.replaced_db);
|
||||
if let Some(reason) = &report.skipped_reason {
|
||||
println!("skipped_reason: {reason}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -290,6 +728,9 @@ mod tests {
|
||||
retention_days: 7,
|
||||
chunk_size: 100,
|
||||
apply: false,
|
||||
vacuum: false,
|
||||
service_unit: DEFAULT_SERVICE_UNIT.to_string(),
|
||||
lock_path: dir.path().join("maintenance.lock"),
|
||||
json: true,
|
||||
};
|
||||
let report = build_report(&cli).unwrap();
|
||||
@@ -312,6 +753,9 @@ mod tests {
|
||||
retention_days: 7,
|
||||
chunk_size: 1,
|
||||
apply: true,
|
||||
vacuum: false,
|
||||
service_unit: DEFAULT_SERVICE_UNIT.to_string(),
|
||||
lock_path: dir.path().join("maintenance.lock"),
|
||||
json: true,
|
||||
};
|
||||
let report = build_report(&cli).unwrap();
|
||||
@@ -321,6 +765,73 @@ mod tests {
|
||||
assert_eq!(count_events(&db), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vacuum_apply_compacts_database_and_preserves_rows() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("sqlite.db");
|
||||
create_vacuum_fixture_db(&db);
|
||||
|
||||
let before = file_size(&db).unwrap();
|
||||
let result = vacuum_sqlite_db(&db, dir.path()).unwrap();
|
||||
let after = file_size(&db).unwrap();
|
||||
|
||||
assert!(result.vacuumed_size_bytes < result.db_size_before_bytes);
|
||||
assert!(after < before);
|
||||
assert_eq!(result.integrity_check, "ok");
|
||||
assert!(result.backup_path.exists());
|
||||
assert_eq!(count_rows(&db), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vacuum_dry_run_skips_mutation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("sqlite.db");
|
||||
create_vacuum_fixture_db(&db);
|
||||
let cli = Cli {
|
||||
db_path: db.clone(),
|
||||
backup_dir: dir.path().join("backups"),
|
||||
session_bucket: None,
|
||||
host: None,
|
||||
retention_days: 7,
|
||||
chunk_size: 100,
|
||||
apply: false,
|
||||
vacuum: true,
|
||||
service_unit: DEFAULT_SERVICE_UNIT.to_string(),
|
||||
lock_path: dir.path().join("maintenance.lock"),
|
||||
json: true,
|
||||
};
|
||||
let report = build_vacuum_report(&cli).unwrap();
|
||||
assert!(!report.backup_created);
|
||||
assert!(!report.replaced_db);
|
||||
assert_eq!(report.skipped_reason.as_deref(), Some("dry-run"));
|
||||
assert_eq!(count_rows(&db), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_refuses_when_lock_exists() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("aw.db");
|
||||
create_fixture_db(&db);
|
||||
let lock_path = dir.path().join("maintenance.lock");
|
||||
fs::write(&lock_path, "busy").unwrap();
|
||||
let cli = Cli {
|
||||
db_path: db.clone(),
|
||||
backup_dir: dir.path().join("backups"),
|
||||
session_bucket: Some("aw-session-events_TEST".to_string()),
|
||||
host: None,
|
||||
retention_days: 7,
|
||||
chunk_size: 1,
|
||||
apply: true,
|
||||
vacuum: false,
|
||||
service_unit: DEFAULT_SERVICE_UNIT.to_string(),
|
||||
lock_path,
|
||||
json: true,
|
||||
};
|
||||
let err = build_report(&cli).unwrap_err().to_string();
|
||||
assert!(err.contains("maintenance lock already exists"));
|
||||
assert_eq!(count_events(&db), 3);
|
||||
}
|
||||
|
||||
fn create_fixture_db(path: &Path) {
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch(
|
||||
@@ -350,10 +861,34 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn create_vacuum_fixture_db(path: &Path) {
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch(
|
||||
"create table items (id integer primary key autoincrement, payload text);",
|
||||
)
|
||||
.unwrap();
|
||||
let payload = "x".repeat(4096);
|
||||
for _ in 0..64 {
|
||||
conn.execute("insert into items (payload) values (?1)", [&payload])
|
||||
.unwrap();
|
||||
}
|
||||
for id in 1..=32 {
|
||||
conn.execute("delete from items where id = ?1", [id])
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn count_events(path: &Path) -> i64 {
|
||||
Connection::open(path)
|
||||
.unwrap()
|
||||
.query_row("select count(*) from events", [], |row| row.get(0))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn count_rows(path: &Path) -> i64 {
|
||||
Connection::open(path)
|
||||
.unwrap()
|
||||
.query_row("select count(*) from items", [], |row| row.get(0))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +285,7 @@ fn is_allowed_python_runtime_path(rel: &str) -> bool {
|
||||
|| rel.starts_with("pfsense/")
|
||||
|| rel == "proxmox/tsj_guardian_bot.py"
|
||||
|| rel == "proxmox/test_tsj_guardian_bot.py"
|
||||
|| rel == "scripts/package_rust_release_binaries.py"
|
||||
}
|
||||
|
||||
fn is_detmir_retired_runtime_path(rel: &str) -> bool {
|
||||
@@ -344,6 +345,9 @@ mod tests {
|
||||
assert!(is_allowed_python_runtime_path(
|
||||
"pfsense/pfsense-aw-poller.py"
|
||||
));
|
||||
assert!(is_allowed_python_runtime_path(
|
||||
"scripts/package_rust_release_binaries.py"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user