feat(detmir): add rust-first operations tooling
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
use std::fs::{self, File};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use flate2::Compression;
|
||||
use flate2::write::GzEncoder;
|
||||
use sha2::{Digest, Sha256};
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
const KIT_DIR: &str = "install-kit-awindows-20260427-211240";
|
||||
const SERVER_CONFIG_DIR: &str = "server-configs-192.168.100.18";
|
||||
const OLD_SERVER_CONFIG_DIR: &str = "server-configs-192.168.100.21";
|
||||
const README: &str = r#"ActivityWatch DetMir Windows Install Kit
|
||||
|
||||
Includes:
|
||||
- windows/* (deploy scripts, collectors, common module, configs/examples)
|
||||
- ansible/* (Windows and AW server playbooks, examples, inventory, tasks)
|
||||
- aw-server/* (server installer, health orchestrator, RU patch loader, host groups, default settings)
|
||||
- scripts/* (install-kit rebuild/validation, quality gates, browser/web smoke checks)
|
||||
- server-configs-192.168.100.18/* (working Windows/RDP config snapshots)
|
||||
|
||||
Source:
|
||||
- Local project snapshot at build time.
|
||||
"#;
|
||||
|
||||
const ANSIBLE_FILES: &[&str] = &[
|
||||
"ansible/README.md",
|
||||
"ansible/deploy_aw_pfsense_poller.yml",
|
||||
"ansible/deploy_aw_server.yml",
|
||||
"ansible/deploy_aw_windows.yml",
|
||||
"ansible/group_vars/all.example.yml",
|
||||
"ansible/group_vars/pfsense-poller.example.yml",
|
||||
"ansible/group_vars/proxmox-matrix.example.yml",
|
||||
"ansible/group_vars/proxmox.example.yml",
|
||||
"ansible/group_vars/windows.example.yml",
|
||||
"ansible/install_full_stack.yml",
|
||||
"ansible/inventory.example.ini",
|
||||
"ansible/provision_proxmox_ct_and_deploy_aw.yml",
|
||||
"ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml",
|
||||
"ansible/tasks/provision_ct_and_deploy_aw.yml",
|
||||
];
|
||||
|
||||
const AW_SERVER_FILES: &[&str] = &[
|
||||
"aw-server/activitywatch-server.service",
|
||||
"aw-server/apply_webui_ru_patch.sh",
|
||||
"aw-server/aw-host-groups.json",
|
||||
"aw-server/aw-ru-patch.js",
|
||||
"aw-server/aw-rus-healthd.py",
|
||||
"aw-server/aw-rus-healthd.service",
|
||||
"aw-server/aw-rus-healthd.timer",
|
||||
"aw-server/aw-browser-smoke.service",
|
||||
"aw-server/aw-browser-smoke.timer",
|
||||
"aw-server/aw-slo-monitor.py",
|
||||
"aw-server/aw-slo-monitor.service",
|
||||
"aw-server/aw-slo-monitor.timer",
|
||||
"aw-server/aw-server.env.example",
|
||||
"aw-server/aw-sw-cleanup.js",
|
||||
"aw-server/aw-worktime-api.py",
|
||||
"aw-server/aw-worktime-api.service",
|
||||
"aw-server/aw-worktime-prewarm.sh",
|
||||
"aw-server/aw-worktime-prewarm.service",
|
||||
"aw-server/aw-worktime-prewarm.timer",
|
||||
"aw-server/aw-worktime-panel.js",
|
||||
"aw-server/health-check.sh",
|
||||
"aw-server/install_aw_server.sh",
|
||||
"aw-server/settings/classes-worktime.json",
|
||||
"aw-server/settings/views-default.json",
|
||||
];
|
||||
|
||||
const WINDOWS_FILES: &[&str] = &[
|
||||
"windows/ActivityWatch.Windows.Common.psd1",
|
||||
"windows/ActivityWatch.Windows.Common.psm1",
|
||||
"windows/browser-domains-native-collector.ps1",
|
||||
"windows/deploy-domain-users.ps1",
|
||||
"windows/deploy-ensemble.ps1",
|
||||
"windows/deploy-single-user.ps1",
|
||||
"windows/AWatchRusCollectorGuardService.cs",
|
||||
"windows/aw-collector-guard.ps1",
|
||||
"windows/install-collector-guard-service.ps1",
|
||||
"windows/dlp-endpoint-signals-collector.ps1",
|
||||
"windows/dlp-policy.example.json",
|
||||
"windows/dlp-policy.native-cross-os.example.json",
|
||||
"windows/email-outbound-collector.ps1",
|
||||
"windows/hardening-recovery.ps1",
|
||||
"windows/migrate-awatch-rus-paths.ps1",
|
||||
"windows/validate-deployment.ps1",
|
||||
"windows/web-category-rules.example.json",
|
||||
"windows/worktime-session-collector.ps1",
|
||||
];
|
||||
|
||||
const SCRIPTS_FILES: &[&str] = &[
|
||||
"scripts/aw-webui-browser-smoke.mjs",
|
||||
"scripts/aw-webui-browser-smoke.sh",
|
||||
"scripts/check_install_kit_vs_repo.sh",
|
||||
"scripts/quality-gate.sh",
|
||||
"scripts/rebuild_install_kit.sh",
|
||||
"scripts/validate_install_kit.sh",
|
||||
"scripts/verify_innosetup_installer.sh",
|
||||
];
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "Rebuild ActivityWatch-Russian Windows install-kit directory and archives")]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = ".")]
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let code = match run() {
|
||||
Ok(()) => 0,
|
||||
Err(err) => {
|
||||
eprintln!("{err:#}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let root = cli
|
||||
.root
|
||||
.canonicalize()
|
||||
.with_context(|| format!("canonicalize root {}", cli.root.display()))?;
|
||||
rebuild(&root)
|
||||
}
|
||||
|
||||
fn rebuild(root: &Path) -> Result<()> {
|
||||
let kit = root.join(KIT_DIR);
|
||||
let preserved_configs = preserve_server_configs(&kit)?;
|
||||
for name in ["ansible", "aw-server", "windows", "scripts"] {
|
||||
remove_if_exists(&kit.join(name))?;
|
||||
}
|
||||
remove_server_config_dirs(&kit)?;
|
||||
|
||||
for rel in ANSIBLE_FILES
|
||||
.iter()
|
||||
.chain(AW_SERVER_FILES)
|
||||
.chain(WINDOWS_FILES)
|
||||
.chain(SCRIPTS_FILES)
|
||||
{
|
||||
copy_file(root, &kit, rel)?;
|
||||
}
|
||||
|
||||
let server_config_dir = kit.join(SERVER_CONFIG_DIR);
|
||||
fs::create_dir_all(&server_config_dir)
|
||||
.with_context(|| format!("create {}", server_config_dir.display()))?;
|
||||
for (name, bytes) in preserved_configs {
|
||||
let path = server_config_dir.join(name);
|
||||
write_file_replace(&path, &bytes).with_context(|| {
|
||||
format!("restore server config into {}", server_config_dir.display())
|
||||
})?;
|
||||
}
|
||||
|
||||
write_file_replace(&kit.join("README-INSTALL-KIT.txt"), README.as_bytes())
|
||||
.with_context(|| format!("write {}", kit.join("README-INSTALL-KIT.txt").display()))?;
|
||||
write_manifest(root, &kit)?;
|
||||
write_zip(root, &kit)?;
|
||||
write_tar(root, &kit)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn preserve_server_configs(kit: &Path) -> Result<Vec<(String, Vec<u8>)>> {
|
||||
let mut out = std::collections::BTreeMap::new();
|
||||
for dir in [OLD_SERVER_CONFIG_DIR, SERVER_CONFIG_DIR] {
|
||||
let path = kit.join(dir);
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
for entry in fs::read_dir(&path).with_context(|| format!("read dir {}", path.display()))? {
|
||||
let entry = entry.with_context(|| format!("read dir entry {}", path.display()))?;
|
||||
let entry_path = entry.path();
|
||||
if entry_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.ends_with(".deployment-config.json"))
|
||||
&& entry_path.is_file()
|
||||
{
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let bytes = fs::read(&entry_path)
|
||||
.with_context(|| format!("read {}", entry_path.display()))?;
|
||||
out.insert(name, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out.into_iter().collect())
|
||||
}
|
||||
|
||||
fn remove_server_config_dirs(kit: &Path) -> Result<()> {
|
||||
if !kit.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(kit).with_context(|| format!("read dir {}", kit.display()))? {
|
||||
let entry = entry.with_context(|| format!("read dir entry {}", kit.display()))?;
|
||||
let path = entry.path();
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with("server-configs-"))
|
||||
{
|
||||
remove_if_exists(&path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_file(root: &Path, kit: &Path, rel: &str) -> Result<()> {
|
||||
let src = root.join(rel);
|
||||
let dest = kit.join(rel);
|
||||
fs::create_dir_all(dest.parent().context("destination parent")?)
|
||||
.with_context(|| format!("create parent for {}", dest.display()))?;
|
||||
let bytes = fs::read(&src).with_context(|| format!("read {}", src.display()))?;
|
||||
write_file_replace(&dest, &bytes)
|
||||
.with_context(|| format!("copy {} to {}", src.display(), dest.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_file_replace(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
remove_if_exists(path)?;
|
||||
fs::write(path, bytes).with_context(|| format!("write {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_if_exists(path: &Path) -> Result<()> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let meta = fs::symlink_metadata(path).with_context(|| format!("stat {}", path.display()))?;
|
||||
if meta.is_dir() {
|
||||
fs::remove_dir_all(path).with_context(|| format!("remove dir {}", path.display()))?;
|
||||
} else {
|
||||
fs::remove_file(path).with_context(|| format!("remove file {}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_manifest(root: &Path, kit: &Path) -> Result<()> {
|
||||
let mut files = Vec::new();
|
||||
collect_files(kit, &mut files)?;
|
||||
files.retain(|path| path.file_name().and_then(|name| name.to_str()) != Some("MANIFEST.txt"));
|
||||
files.sort();
|
||||
let manifest = kit.join("MANIFEST.txt");
|
||||
let mut out = String::new();
|
||||
for path in files {
|
||||
let digest = sha256_file(&path)?;
|
||||
let rel = path
|
||||
.strip_prefix(root)
|
||||
.with_context(|| format!("strip root prefix from {}", path.display()))?;
|
||||
out.push_str(&format!("{digest} {}\n", slash_path(rel)));
|
||||
}
|
||||
write_file_replace(&manifest, out.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_zip(root: &Path, kit: &Path) -> Result<()> {
|
||||
let archive_path = root.join(format!("{KIT_DIR}.zip"));
|
||||
remove_if_exists(&archive_path)?;
|
||||
let file = File::create(&archive_path)
|
||||
.with_context(|| format!("create {}", archive_path.display()))?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
let mut files = Vec::new();
|
||||
collect_files(kit, &mut files)?;
|
||||
files.sort();
|
||||
for path in files {
|
||||
let rel = path
|
||||
.strip_prefix(root)
|
||||
.with_context(|| format!("strip root prefix from {}", path.display()))?;
|
||||
let name = slash_path(rel);
|
||||
zip.start_file(name, options).context("start zip file")?;
|
||||
let mut input = File::open(&path).with_context(|| format!("open {}", path.display()))?;
|
||||
std::io::copy(&mut input, &mut zip)
|
||||
.with_context(|| format!("write zip {}", path.display()))?;
|
||||
}
|
||||
zip.finish().context("finish zip")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_tar(root: &Path, kit: &Path) -> Result<()> {
|
||||
let archive_path = root.join(format!("{KIT_DIR}.tar.gz"));
|
||||
remove_if_exists(&archive_path)?;
|
||||
let file = File::create(&archive_path)
|
||||
.with_context(|| format!("create {}", archive_path.display()))?;
|
||||
let encoder = GzEncoder::new(file, Compression::default());
|
||||
let mut builder = tar::Builder::new(encoder);
|
||||
let mut files = Vec::new();
|
||||
collect_files(kit, &mut files)?;
|
||||
files.sort();
|
||||
for path in files {
|
||||
builder
|
||||
.append_path_with_name(
|
||||
&path,
|
||||
slash_path(
|
||||
path.strip_prefix(root)
|
||||
.with_context(|| format!("strip root prefix from {}", path.display()))?,
|
||||
),
|
||||
)
|
||||
.with_context(|| format!("append tar {}", path.display()))?;
|
||||
}
|
||||
builder.finish().context("finish tar")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_files(path: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
||||
for entry in fs::read_dir(path).with_context(|| format!("read dir {}", path.display()))? {
|
||||
let entry = entry.with_context(|| format!("read dir entry {}", path.display()))?;
|
||||
let entry_path = entry.path();
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.with_context(|| format!("read file type {}", entry_path.display()))?;
|
||||
if file_type.is_dir() {
|
||||
collect_files(&entry_path, out)?;
|
||||
} else if (file_type.is_file() || file_type.is_symlink()) && entry_path.is_file() {
|
||||
out.push(entry_path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sha256_file(path: &Path) -> Result<String> {
|
||||
let mut file = File::open(path).with_context(|| format!("open {}", path.display()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
let read = file
|
||||
.read(&mut buf)
|
||||
.with_context(|| format!("read {}", path.display()))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..read]);
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
fn slash_path(path: &Path) -> String {
|
||||
path.components()
|
||||
.map(|component| component.as_os_str().to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::{preserve_server_configs, remove_server_config_dirs};
|
||||
|
||||
#[test]
|
||||
fn preserves_new_server_configs_over_old_duplicates() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let kit = tmp.path();
|
||||
let old = kit.join("server-configs-192.168.100.21");
|
||||
let new = kit.join("server-configs-192.168.100.18");
|
||||
fs::create_dir_all(&old).unwrap();
|
||||
fs::create_dir_all(&new).unwrap();
|
||||
fs::write(old.join("a.deployment-config.json"), "old").unwrap();
|
||||
fs::write(new.join("a.deployment-config.json"), "new").unwrap();
|
||||
let configs = preserve_server_configs(kit).unwrap();
|
||||
assert_eq!(configs.len(), 1);
|
||||
assert_eq!(configs[0].0, "a.deployment-config.json");
|
||||
assert_eq!(configs[0].1, b"new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removes_server_config_dirs_only() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let kit = tmp.path();
|
||||
fs::create_dir_all(kit.join("server-configs-x")).unwrap();
|
||||
fs::create_dir_all(kit.join("windows")).unwrap();
|
||||
remove_server_config_dirs(kit).unwrap();
|
||||
assert!(!kit.join("server-configs-x").exists());
|
||||
assert!(kit.join("windows").exists());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user