Fix reconcile with remote
This commit is contained in:
+55
-8
@@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env -S nix shell nixpkgs#bash --command bash
|
||||||
#
|
#
|
||||||
# vm.sh - Incus VM launcher for musicfs-server
|
# vm.sh - Incus VM launcher for musicfs-server
|
||||||
#
|
#
|
||||||
@@ -30,6 +30,8 @@
|
|||||||
# MUSIC_MOUNT where it appears inside the VM (default: /music)
|
# MUSIC_MOUNT where it appears inside the VM (default: /music)
|
||||||
# LISTEN_PORT gRPC port (host:vm forwarded) (default: 50051)
|
# LISTEN_PORT gRPC port (host:vm forwarded) (default: 50051)
|
||||||
# RUST_LOG tracing filter for the server (default: info)
|
# RUST_LOG tracing filter for the server (default: info)
|
||||||
|
# READONLY mount music as read-only (0=writable) (default: 1)
|
||||||
|
# VM_IP static IPv4 for the VM (default: auto-detected from incusbr0, gateway.145)
|
||||||
#
|
#
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -40,8 +42,15 @@ MUSIC_SOURCE="${MUSIC_SOURCE:-$HOME/Music}"
|
|||||||
MUSIC_MOUNT="${MUSIC_MOUNT:-/music}"
|
MUSIC_MOUNT="${MUSIC_MOUNT:-/music}"
|
||||||
LISTEN_PORT="${LISTEN_PORT:-50051}"
|
LISTEN_PORT="${LISTEN_PORT:-50051}"
|
||||||
RUST_LOG="${RUST_LOG:-info}"
|
RUST_LOG="${RUST_LOG:-info}"
|
||||||
|
detect_static_ip() {
|
||||||
|
if [[ -n "${VM_IP:-}" ]]; then echo "$VM_IP"; return; fi
|
||||||
|
local bridge_cidr
|
||||||
|
bridge_cidr="$(incus network get incusbr0 ipv4.address 2>/dev/null || true)"
|
||||||
|
if [[ -z "$bridge_cidr" ]]; then echo ""; return; fi
|
||||||
|
local gateway="${bridge_cidr%%/*}"
|
||||||
|
echo "${gateway%.*}.145"
|
||||||
|
}
|
||||||
|
|
||||||
# Fixed inside the VM; the bundle's interpreter+rpath point here.
|
|
||||||
SERVER_DIR="/opt/musicfs"
|
SERVER_DIR="/opt/musicfs"
|
||||||
LOG_DIR="/var/log/musicfs"
|
LOG_DIR="/var/log/musicfs"
|
||||||
BIN_NAME="musicfs-server"
|
BIN_NAME="musicfs-server"
|
||||||
@@ -60,7 +69,12 @@ vm_running() { incus list "$VM_NAME" -f csv -c n,s 2>/dev/null | grep -q "$VM_NA
|
|||||||
device_exists() { incus config device show "$VM_NAME" 2>/dev/null | grep -q "^$1:"; }
|
device_exists() { incus config device show "$VM_NAME" 2>/dev/null | grep -q "^$1:"; }
|
||||||
|
|
||||||
# incus -c 4 emits "<ip> (<iface>)"; keep only the address.
|
# incus -c 4 emits "<ip> (<iface>)"; keep only the address.
|
||||||
vm_ip() { incus list "$VM_NAME" -f csv -c 4 2>/dev/null | head -1 | awk '{print $1}'; }
|
vm_ip() {
|
||||||
|
local ip
|
||||||
|
ip="$(incus list "$VM_NAME" -f csv -c 4 2>/dev/null | head -1 | awk '{print $1}')"
|
||||||
|
if [[ -n "$ip" ]]; then echo "$ip"; return; fi
|
||||||
|
detect_static_ip
|
||||||
|
}
|
||||||
|
|
||||||
# ──────────────────────────── build + bundle ───────────────────────────
|
# ──────────────────────────── build + bundle ───────────────────────────
|
||||||
# Emits the host path to the prepared bundle dir on stdout.
|
# Emits the host path to the prepared bundle dir on stdout.
|
||||||
@@ -120,10 +134,24 @@ create_vm() {
|
|||||||
log "creating VM '$VM_NAME' from $IMAGE ..."
|
log "creating VM '$VM_NAME' from $IMAGE ..."
|
||||||
incus init "$IMAGE" "$VM_NAME" --vm
|
incus init "$IMAGE" "$VM_NAME" --vm
|
||||||
|
|
||||||
|
local static_ip
|
||||||
|
static_ip="$(detect_static_ip)"
|
||||||
|
if [[ -n "$static_ip" ]]; then
|
||||||
|
log "reserving static IP: $static_ip"
|
||||||
|
incus config device add "$VM_NAME" eth0 nic \
|
||||||
|
network=incusbr0 name=eth0 ipv4.address="$static_ip"
|
||||||
|
fi
|
||||||
|
|
||||||
if ! device_exists music; then
|
if ! device_exists music; then
|
||||||
log "sharing music: $MUSIC_SOURCE -> $MUSIC_MOUNT (readonly, virtiofs)"
|
local ro_flag="readonly=true"
|
||||||
|
local mode="read-only"
|
||||||
|
if [[ "$READONLY" == "0" ]]; then
|
||||||
|
ro_flag="readonly=false"
|
||||||
|
mode="read-write"
|
||||||
|
fi
|
||||||
|
log "sharing music: $MUSIC_SOURCE -> $MUSIC_MOUNT ($mode, virtiofs)"
|
||||||
incus config device add "$VM_NAME" music disk \
|
incus config device add "$VM_NAME" music disk \
|
||||||
source="$MUSIC_SOURCE" path="$MUSIC_MOUNT" readonly=true
|
source="$MUSIC_SOURCE" path="$MUSIC_MOUNT" "$ro_flag"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# No proxy: Incus VM proxies need NAT mode + a *static* instance IP (DHCP is
|
# No proxy: Incus VM proxies need NAT mode + a *static* instance IP (DHCP is
|
||||||
@@ -255,7 +283,15 @@ print_status() {
|
|||||||
printf ' ipv4: %s\n' "${ip:-<none>}"
|
printf ' ipv4: %s\n' "${ip:-<none>}"
|
||||||
printf ' image: %s\n' "$IMAGE"
|
printf ' image: %s\n' "$IMAGE"
|
||||||
printf ' listen: 0.0.0.0:%s (reach via VM bridge IP above)\n' "$LISTEN_PORT"
|
printf ' listen: 0.0.0.0:%s (reach via VM bridge IP above)\n' "$LISTEN_PORT"
|
||||||
printf ' music: %s -> %s\n' "$MUSIC_SOURCE" "$MUSIC_MOUNT"
|
local ro_actual="read-only"
|
||||||
|
if vm_exists; then
|
||||||
|
local ro_val
|
||||||
|
ro_val="$(incus config device get "$VM_NAME" music readonly 2>/dev/null || true)"
|
||||||
|
[[ "$ro_val" == "false" ]] && ro_actual="read-write"
|
||||||
|
else
|
||||||
|
[[ "$READONLY" == "0" ]] && ro_actual="read-write"
|
||||||
|
fi
|
||||||
|
printf ' music: %s -> %s (%s)\n' "$MUSIC_SOURCE" "$MUSIC_MOUNT" "$ro_actual"
|
||||||
printf ' server: %s/server\n' "$SERVER_DIR"
|
printf ' server: %s/server\n' "$SERVER_DIR"
|
||||||
if [ -n "$ip" ]; then
|
if [ -n "$ip" ]; then
|
||||||
printf '\n test: grpcurl -plaintext %s:%s list\n\n' "$ip" "$LISTEN_PORT"
|
printf '\n test: grpcurl -plaintext %s:%s list\n\n' "$ip" "$LISTEN_PORT"
|
||||||
@@ -273,7 +309,18 @@ usage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
case "${1:-up}" in
|
local cmd="${1:-up}"
|
||||||
|
shift || true
|
||||||
|
while (( $# )); do
|
||||||
|
case "$1" in
|
||||||
|
--writable) [[ -z "${READONLY:-}" ]] && READONLY=0 ;;
|
||||||
|
--readonly) [[ -z "${READONLY:-}" ]] && READONLY=1 ;;
|
||||||
|
*) die "unknown flag '$1'. Try: $0 --help" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
READONLY="${READONLY:-1}"
|
||||||
|
case "$cmd" in
|
||||||
up) cmd_up ;;
|
up) cmd_up ;;
|
||||||
redeploy) cmd_redeploy ;;
|
redeploy) cmd_redeploy ;;
|
||||||
restart) cmd_restart ;;
|
restart) cmd_restart ;;
|
||||||
@@ -283,7 +330,7 @@ main() {
|
|||||||
down) cmd_down ;;
|
down) cmd_down ;;
|
||||||
destroy) cmd_destroy ;;
|
destroy) cmd_destroy ;;
|
||||||
-h|--help|help) usage ;;
|
-h|--help|help) usage ;;
|
||||||
*) die "unknown command '$1'. Try: $0 --help" ;;
|
*) die "unknown command '$cmd'. Try: $0 --help" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+67
-53
@@ -166,59 +166,9 @@ impl NetworkOrigin {
|
|||||||
}
|
}
|
||||||
*self.latest_manifest.write().unwrap() = current_manifest.clone();
|
*self.latest_manifest.write().unwrap() = current_manifest.clone();
|
||||||
|
|
||||||
// 5. Convert manifest entries → Items.
|
let snapshot =
|
||||||
let dest = self.destination.clone();
|
build_snapshot_from_manifest(¤t_manifest, &self.destination, &self.client)
|
||||||
let mut snapshot = BTreeMap::new();
|
.await?;
|
||||||
let root_attrs = FileAttrs {
|
|
||||||
size: 0,
|
|
||||||
blocks: 0,
|
|
||||||
atime: SystemTime::UNIX_EPOCH,
|
|
||||||
mtime: SystemTime::UNIX_EPOCH,
|
|
||||||
ctime: SystemTime::UNIX_EPOCH,
|
|
||||||
crtime: SystemTime::UNIX_EPOCH,
|
|
||||||
perm: 0o755,
|
|
||||||
nlink: 2,
|
|
||||||
uid: 0,
|
|
||||||
gid: 0,
|
|
||||||
rdev: 0,
|
|
||||||
blksize: 4096,
|
|
||||||
};
|
|
||||||
snapshot.insert(
|
|
||||||
INodeNo::ROOT,
|
|
||||||
Item::new(
|
|
||||||
INodeNo::ROOT,
|
|
||||||
INodeNo::ROOT,
|
|
||||||
"/".to_string(),
|
|
||||||
dest.clone(),
|
|
||||||
dest.clone(),
|
|
||||||
FileType::Directory,
|
|
||||||
root_attrs,
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
let source_root = PathBuf::from("/");
|
|
||||||
for (_id, entry) in ¤t_manifest {
|
|
||||||
let item = manifest_entry_to_item(entry, &source_root, &mut snapshot);
|
|
||||||
snapshot.insert(item.inode, item);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Reconcile with DB exactly like LocalOrigin: insert/update/delete
|
|
||||||
// rows, then restore music metadata + virtual paths.
|
|
||||||
let db_items: std::collections::HashMap<i64, item_entities::Model> =
|
|
||||||
item_entities::Entity::find()
|
|
||||||
.all(&self.client)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
error!(error = %e, "network snapshot: db_items DB read failed");
|
|
||||||
io_err(e)
|
|
||||||
})?
|
|
||||||
.into_iter()
|
|
||||||
.map(|e| (e.inode, e))
|
|
||||||
.collect();
|
|
||||||
sync_items_to_db(&snapshot, &db_items, &self.client).await;
|
|
||||||
restore_music_metadata_from_db(&mut snapshot, &db_items, &self.client).await;
|
|
||||||
restore_virtual_paths(&mut snapshot, &db_items, &source_root);
|
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
changed = response.changed.len(),
|
changed = response.changed.len(),
|
||||||
@@ -230,6 +180,70 @@ impl NetworkOrigin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a complete Items snapshot from the server manifest, sync it to the
|
||||||
|
/// DB, and restore music metadata + virtual paths from existing DB rows.
|
||||||
|
///
|
||||||
|
/// Shared between `snapshot_async` (initial mount) and the watcher's
|
||||||
|
/// `reconcile_once` (runtime updates) so both paths produce identical
|
||||||
|
/// snapshots and keep the DB in sync.
|
||||||
|
pub(crate) async fn build_snapshot_from_manifest(
|
||||||
|
manifest: &BTreeMap<u64, ProtoManifestEntry>,
|
||||||
|
destination: &Path,
|
||||||
|
client: &sea_orm::DatabaseConnection,
|
||||||
|
) -> io::Result<BTreeMap<INodeNo, Item>> {
|
||||||
|
let source_root = PathBuf::from("/");
|
||||||
|
let mut snapshot = BTreeMap::new();
|
||||||
|
let root_attrs = FileAttrs {
|
||||||
|
size: 0,
|
||||||
|
blocks: 0,
|
||||||
|
atime: SystemTime::UNIX_EPOCH,
|
||||||
|
mtime: SystemTime::UNIX_EPOCH,
|
||||||
|
ctime: SystemTime::UNIX_EPOCH,
|
||||||
|
crtime: SystemTime::UNIX_EPOCH,
|
||||||
|
perm: 0o755,
|
||||||
|
nlink: 2,
|
||||||
|
uid: 0,
|
||||||
|
gid: 0,
|
||||||
|
rdev: 0,
|
||||||
|
blksize: 4096,
|
||||||
|
};
|
||||||
|
snapshot.insert(
|
||||||
|
INodeNo::ROOT,
|
||||||
|
Item::new(
|
||||||
|
INodeNo::ROOT,
|
||||||
|
INodeNo::ROOT,
|
||||||
|
"/".to_string(),
|
||||||
|
destination.to_path_buf(),
|
||||||
|
destination.to_path_buf(),
|
||||||
|
FileType::Directory,
|
||||||
|
root_attrs,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (_id, entry) in manifest {
|
||||||
|
let item = manifest_entry_to_item(entry, &source_root, &mut snapshot);
|
||||||
|
snapshot.insert(item.inode, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
let db_items: std::collections::HashMap<i64, item_entities::Model> =
|
||||||
|
item_entities::Entity::find()
|
||||||
|
.all(client)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
error!(error = %e, "build_snapshot_from_manifest: DB read failed");
|
||||||
|
io_err(e)
|
||||||
|
})?
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| (e.inode, e))
|
||||||
|
.collect();
|
||||||
|
sync_items_to_db(&snapshot, &db_items, client).await;
|
||||||
|
restore_music_metadata_from_db(&mut snapshot, &db_items, client).await;
|
||||||
|
restore_virtual_paths(&mut snapshot, &db_items, &source_root);
|
||||||
|
|
||||||
|
return Ok(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
fn manifest_entry_to_item(
|
fn manifest_entry_to_item(
|
||||||
entry: &ProtoManifestEntry,
|
entry: &ProtoManifestEntry,
|
||||||
source_root: &Path,
|
source_root: &Path,
|
||||||
|
|||||||
@@ -47,11 +47,6 @@ impl NetworkOriginFileWatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `files` is currently unused at runtime by the network watcher — the
|
|
||||||
/// authoritative post-reconcile snapshot is written to the DB, and the next
|
|
||||||
/// FUSE `readdir`/`lookup` will reflect it because FuseFs re-locks the map.
|
|
||||||
/// The trait signature still requires it for parity with LocalOriginFileWatcher;
|
|
||||||
/// a future refactor can rebuild the map in place here.
|
|
||||||
impl FileWatcher for NetworkOriginFileWatcher {
|
impl FileWatcher for NetworkOriginFileWatcher {
|
||||||
fn watch(&self, files: Arc<std::sync::Mutex<BTreeMap<INodeNo, Item>>>) -> WatcherHandle {
|
fn watch(&self, files: Arc<std::sync::Mutex<BTreeMap<INodeNo, Item>>>) -> WatcherHandle {
|
||||||
let runtime_handle = self.runtime_handle.clone();
|
let runtime_handle = self.runtime_handle.clone();
|
||||||
@@ -160,8 +155,29 @@ impl WatcherState {
|
|||||||
}
|
}
|
||||||
*self.latest_manifest.write().unwrap() = current_manifest.clone();
|
*self.latest_manifest.write().unwrap() = current_manifest.clone();
|
||||||
|
|
||||||
let _ = self.destination.clone();
|
let new_snapshot =
|
||||||
let _ = self.files.clone();
|
super::build_snapshot_from_manifest(¤t_manifest, &self.destination, &self.client)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut files = self.files.lock().unwrap();
|
||||||
|
files.retain(|ino, _| new_snapshot.contains_key(ino));
|
||||||
|
for (ino, new_item) in &new_snapshot {
|
||||||
|
match files.get(ino) {
|
||||||
|
Some(existing) if existing.hash == new_item.hash => {}
|
||||||
|
_ => {
|
||||||
|
files.insert(*ino, new_item.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
changed = response.changed.len(),
|
||||||
|
deleted = response.deleted.len(),
|
||||||
|
total = current_manifest.len(),
|
||||||
|
"network watcher: reconcile applied"
|
||||||
|
);
|
||||||
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-17
@@ -2,6 +2,7 @@ use std::{
|
|||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
thread,
|
thread,
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use notify::{EventKind, RecursiveMode, Watcher};
|
use notify::{EventKind, RecursiveMode, Watcher};
|
||||||
@@ -10,6 +11,8 @@ use tokio::sync::broadcast;
|
|||||||
use crate::server::state::ServerState;
|
use crate::server::state::ServerState;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
/// A change observed by the watcher. Pushed onto the broadcast channel for
|
/// A change observed by the watcher. Pushed onto the broadcast channel for
|
||||||
/// `/events` subscribers. The client treats these as wake-ups: correctness
|
/// `/events` subscribers. The client treats these as wake-ups: correctness
|
||||||
/// always rests on the subsequent `/manifest` hash diff.
|
/// always rests on the subsequent `/manifest` hash diff.
|
||||||
@@ -76,25 +79,37 @@ fn run_watcher_loop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
info!(source = %source.display(), "server watcher: scanning for changes");
|
info!(source = %source.display(), "server watcher: scanning for changes");
|
||||||
for res in rx {
|
loop {
|
||||||
match res {
|
match rx.recv_timeout(POLL_INTERVAL) {
|
||||||
Ok(event) => match event.kind {
|
Ok(res) => match res {
|
||||||
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
|
Ok(event) => match event.kind {
|
||||||
let kind = match event.kind {
|
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
|
||||||
EventKind::Create(_) => ChangeKind::Create,
|
let kind = match event.kind {
|
||||||
EventKind::Modify(_) => ChangeKind::Modify,
|
EventKind::Create(_) => ChangeKind::Create,
|
||||||
EventKind::Remove(_) => ChangeKind::Remove,
|
EventKind::Modify(_) => ChangeKind::Modify,
|
||||||
_ => continue,
|
EventKind::Remove(_) => ChangeKind::Remove,
|
||||||
};
|
_ => continue,
|
||||||
if let Err(e) = state.replace_all(&source) {
|
};
|
||||||
error!(error = %e, "server watcher: state refresh failed");
|
if let Err(e) = state.replace_all(&source) {
|
||||||
continue;
|
error!(error = %e, "server watcher: state refresh failed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let _ = events_tx.send(ChangeEvent { kind });
|
||||||
}
|
}
|
||||||
let _ = events_tx.send(ChangeEvent { kind });
|
_ => {}
|
||||||
}
|
},
|
||||||
_ => {}
|
Err(e) => error!(error = %e, "server watcher: inotify error"),
|
||||||
},
|
},
|
||||||
Err(e) => error!(error = %e, "server watcher: inotify error"),
|
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||||
|
if let Err(e) = state.replace_all(&source) {
|
||||||
|
error!(error = %e, "server watcher: poll rescan failed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let _ = events_tx.send(ChangeEvent {
|
||||||
|
kind: ChangeKind::Modify,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user