-
Notifications
You must be signed in to change notification settings - Fork 5
feat: allow partial scans #1492
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,7 +20,7 @@ | |
|
|
||
| use std::{ | ||
| cell::RefCell, | ||
| collections::HashMap, | ||
| collections::{HashMap, HashSet}, | ||
| fs::Metadata, | ||
| io, | ||
| ops::{Deref, DerefMut}, | ||
|
|
@@ -48,7 +48,7 @@ use tokio::{ | |
| use crate::{ | ||
| bpf::Bpf, | ||
| event::Event, | ||
| host_info, | ||
| host_info::{self, remove_host_mount}, | ||
| metrics::host_scanner::{HostScannerMetrics, ScanLabels}, | ||
| }; | ||
|
|
||
|
|
@@ -105,6 +105,7 @@ pub struct HostScanner { | |
| metrics: HostScannerMetrics, | ||
|
|
||
| paths_globset: GlobSet, | ||
| paths_patterns: Vec<PathBuf>, | ||
| } | ||
|
|
||
| impl HostScanner { | ||
|
|
@@ -119,9 +120,8 @@ impl HostScanner { | |
| let kernel_inode_map = RefCell::new(bpf.take_inode_map()?); | ||
| let inode_map = RefCell::new(InodeMap::new()); | ||
| let (tx, output) = mpsc::channel(100); | ||
| let paths_globset = HostScanner::build_globset(paths.borrow().as_slice())?; | ||
|
|
||
| let host_scanner = HostScanner { | ||
| let mut host_scanner = HostScanner { | ||
| kernel_inode_map, | ||
| inode_map, | ||
| paths, | ||
|
|
@@ -130,45 +130,50 @@ impl HostScanner { | |
| tx, | ||
| introspection, | ||
| metrics, | ||
| paths_globset, | ||
| paths_globset: GlobSet::empty(), | ||
| paths_patterns: Vec::new(), | ||
| }; | ||
|
|
||
| host_scanner.reload_paths_config()?; | ||
|
|
||
| // Run an initial scan to fill in the inode map | ||
| host_scanner.scan()?; | ||
|
|
||
| Ok((host_scanner, output)) | ||
| } | ||
|
|
||
| fn build_globset(paths: &[PathBuf]) -> anyhow::Result<GlobSet> { | ||
| fn reload_paths_config(&mut self) -> anyhow::Result<()> { | ||
| let paths = self.paths.borrow(); | ||
| let mut builder = GlobSetBuilder::new(); | ||
| let mut patterns = Vec::with_capacity(paths.len()); | ||
|
|
||
| for p in paths.iter() { | ||
| patterns.push(host_info::prepend_host_mount(p)); | ||
|
|
||
| let Some(glob_str) = p.to_str() else { | ||
| bail!("failed to convert path {} to string", p.display()); | ||
| }; | ||
|
|
||
| builder.add( | ||
| Glob::new(glob_str) | ||
| .with_context(|| format!("invalid glob {}", glob_str)) | ||
| .unwrap(), | ||
| ); | ||
| builder.add(Glob::new(glob_str).with_context(|| format!("invalid glob {}", glob_str))?); | ||
| } | ||
| Ok(builder.build()?) | ||
|
|
||
| self.paths_globset = builder.build()?; | ||
| self.paths_patterns = patterns; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| fn scan(&self) -> anyhow::Result<()> { | ||
| info!("Host scan started"); | ||
| let start = Instant::now(); | ||
| self.metrics.scan_inc(ScanLabels::Scans); | ||
| let config = self.paths.borrow(); | ||
|
|
||
| // Cleanup any items that are either: | ||
| // * Not configured to be monitored anymore. | ||
| // * Are configured to be monitored but no longer are found in | ||
| // the file system. | ||
| self.inode_map.borrow_mut().retain(|inode, path| { | ||
| if config.iter().any(|prefix| path.starts_with(prefix)) | ||
| && host_info::prepend_host_mount(path).exists() | ||
| { | ||
| if self.paths_globset.is_match(&path) && host_info::prepend_host_mount(path).exists() { | ||
| true | ||
| } else { | ||
| let _ = self.kernel_inode_map.borrow_mut().remove(inode); | ||
|
|
@@ -177,9 +182,8 @@ impl HostScanner { | |
| } | ||
| }); | ||
|
|
||
| for pattern in self.paths.borrow().iter() { | ||
| let path = host_info::prepend_host_mount(pattern); | ||
| self.scan_inner(&path)?; | ||
| for path in &self.paths_patterns { | ||
| self.scan_inner(path)?; | ||
| } | ||
| let duration = start.elapsed(); | ||
| self.metrics.scan_duration.observe(duration.as_secs_f64()); | ||
|
|
@@ -264,14 +268,45 @@ impl HostScanner { | |
| } | ||
| } | ||
|
|
||
| /// Do a partial scan of any pattern that matches the provided path | ||
| /// | ||
| /// This includes glob expansion matching and any patterns with a | ||
| /// base path (the path up to the first glob special character) that | ||
| /// matches the supplied path. | ||
| fn scan_partial(&self, path: &Path) -> anyhow::Result<()> { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do I get it right, that full scan updates
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, the dangling inodes are potentially there, I don't know if we have an easy way to remove these in a partial scan, probably need to address this outside of the partial scan logic as part of the event handling. I'll put some thought into it before merging the PR.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually, in order to properly cleanup inodes from a partial scan we will need proper hardlink tracking if we want to prevent removing an inode that is now found at a different path. In the meantime I think we can rely on the periodic scan to properly cleanup the inode maps, WDYT?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Makes sense, but it also probably worth it extending metrics around
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now that I think about this, it would also be cool to have same metrics as for the regular scan. E.g. we can log the duration, since in the worst case partial scan could be as heavy as a full scan.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I've been looking into an excuse for adding
Makes sense, I'll add this one as well. Depending on the size of the changes I might break the metrics work into a follow up PR and stack it. |
||
| let scan_prefix_patterns = | ||
| self.paths_patterns | ||
| .iter() | ||
| .enumerate() | ||
| .filter_map(|(i, pattern)| { | ||
| remove_host_mount(pattern) | ||
| .to_str()? | ||
| .split(['*', '?', '[', '{']) | ||
| .next()? | ||
| .starts_with(path.to_str()?) | ||
| .then_some(i) | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| let scan_glob_index = self.paths_globset.matches(path); | ||
|
|
||
| // De-duplicate the indexes | ||
| let scan_set = scan_prefix_patterns | ||
| .chain(scan_glob_index.iter().copied()) | ||
| .collect::<HashSet<_>>(); | ||
|
|
||
| for pattern in scan_set.iter().map(|index| &self.paths_patterns[*index]) { | ||
| self.scan_inner(pattern)?; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| Ok(()) | ||
| } | ||
|
|
||
| fn update_entry(&self, path: &Path, metadata: &Metadata) -> anyhow::Result<()> { | ||
| let inode = inode_key_t { | ||
| inode: metadata.st_ino(), | ||
| dev: metadata.st_dev(), | ||
| }; | ||
|
|
||
| let host_path = host_info::remove_host_mount(path); | ||
| self.update_entry_with_inode(inode, host_path)?; | ||
| self.update_entry_with_inode(inode, host_path.to_path_buf())?; | ||
|
|
||
| debug!("Added entry for {}: {inode:?}", path.display()); | ||
| Ok(()) | ||
|
|
@@ -491,23 +526,17 @@ You can increase this limit with: | |
| } | ||
|
|
||
| /// Handle a mount being modified in a monitored directory. | ||
| /// | ||
| /// This should really do a partial scan of the directory where the | ||
| /// mount is being changed, but we don't have an easy way to do that | ||
| /// at the moment, so we trigger a full scan instead. | ||
| fn handle_mount_event(&self) { | ||
| if let Err(e) = self.scan() { | ||
| fn handle_mount_event(&self, event: &Event) { | ||
| if let Err(e) = self.scan_partial(event.get_host_path()) { | ||
| warn!("Host scan failed: {e:?}"); | ||
| } | ||
| } | ||
|
|
||
| /// Handle symlink events by scanning the filesystem | ||
| fn handle_symlink_event(&self) -> anyhow::Result<()> { | ||
| fn handle_symlink_event(&self, event: &Event) -> anyhow::Result<()> { | ||
| // Since `glob` follows symlinks unconditionally, we need to do | ||
| // so as well. | ||
| // | ||
| // TODO: do a partial scan of the symlink, rather than a full scan | ||
| self.scan() | ||
| self.scan_partial(event.get_host_path()) | ||
| } | ||
|
|
||
| /// Periodically notify the host scanner main task that a scan needs | ||
|
|
@@ -576,12 +605,6 @@ You can increase this limit with: | |
| warn!("Failed to handle creation event: {e}"); | ||
| } | ||
|
|
||
| // Handle mount events and move on. | ||
| if event.is_mount_related() { | ||
| self.handle_mount_event(); | ||
| continue; | ||
| } | ||
|
|
||
| if let Some(host_path) = self.get_host_path(Some(event.get_inode())) { | ||
| self.metrics.scan_inc(ScanLabels::InodeHit); | ||
| event.set_host_path(host_path); | ||
|
|
@@ -592,6 +615,12 @@ You can increase this limit with: | |
| event.set_old_host_path(host_path); | ||
| } | ||
|
|
||
| // Handle mount events and move on. | ||
|
erthalion marked this conversation as resolved.
|
||
| if event.is_mount_related() { | ||
| self.handle_mount_event(&event); | ||
| continue; | ||
| } | ||
|
|
||
| // Remove inode from the map | ||
| if event.is_deletion() { | ||
| self.handle_unlink_event(&event); | ||
|
|
@@ -603,7 +632,7 @@ You can increase this limit with: | |
| } | ||
|
|
||
| if event.is_symlink() && | ||
| let Err(e) = self.handle_symlink_event() { | ||
| let Err(e) = self.handle_symlink_event(&event) { | ||
| warn!("Failed to handle symlink event: {e:?}"); | ||
| } | ||
|
|
||
|
|
@@ -640,7 +669,7 @@ You can increase this limit with: | |
| } | ||
| _ = scan_trigger.notified() => self.scan()?, | ||
| _ = self.paths.changed() => { | ||
| self.paths_globset = HostScanner::build_globset(self.paths.borrow().as_slice())?; | ||
| self.reload_paths_config()?; | ||
| self.scan()?; | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,15 @@ | |
| from server import EventServer | ||
|
|
||
|
|
||
| def reload_config( | ||
| fact: docker.models.containers.Container, config: dict, config_file: str | ||
| ): | ||
| with open(config_file, 'w') as f: | ||
| yaml.dump(config, f) | ||
| fact.kill('SIGHUP') | ||
| sleep(0.1) | ||
|
Comment on lines
+17
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Wait for reload completion instead of a fixed delay.
Wait for observable reload completion before returning. For this integration suite, the enabled test-only Based on learnings, introspection endpoints are dev/testing-only. 🤖 Prompt for AI AgentsSource: Learnings
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @CodeRabbit, this is a good point, but change how the tests work is outside the scope of the PR. Will address when we have time to work on test tech-debt. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Would you like me to open a follow-up GitHub issue for the reload synchronization test technical debt? You are interacting with an AI system. |
||
|
|
||
|
|
||
| @pytest.fixture | ||
| def wildcard_config( | ||
| fact: docker.models.containers.Container, | ||
|
|
@@ -23,12 +32,22 @@ def wildcard_config( | |
| f'{monitored_dir}/*.conf', | ||
| f'{monitored_dir}/**/test-*.log', | ||
| ] | ||
| with open(config_file, 'w') as f: | ||
| yaml.dump(config, f) | ||
|
|
||
| # reload the config | ||
| fact.kill('SIGHUP') | ||
| sleep(0.1) | ||
| reload_config(fact, config, config_file) | ||
| return config, config_file | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def partial_path_match_wildcard( | ||
| fact: docker.models.containers.Container, | ||
| fact_config: tuple[dict, str], | ||
| ignored_dir: str, | ||
| ): | ||
| config, config_file = fact_config | ||
| partial_dir = ignored_dir.rsplit('-', 1)[0] | ||
| config['paths'] = [f'{partial_dir}*/**/*', f'{partial_dir}*'] | ||
|
|
||
| reload_config(fact, config, config_file) | ||
| return config, config_file | ||
|
|
||
|
|
||
|
|
@@ -197,3 +216,75 @@ def test_multiple_patterns( | |
| ] | ||
|
|
||
| server.wait_events(events) | ||
|
|
||
|
|
||
| def test_partial_dir_pattern( | ||
| partial_path_match_wildcard: tuple[dict, str], | ||
| ignored_dir: str, | ||
| server: EventServer, | ||
| ): | ||
| process = Process.from_proc() | ||
| file = os.path.join(ignored_dir, 'file.txt') | ||
| with open(file, 'w') as f: | ||
| f.write('This is a test') | ||
|
|
||
| server.wait_events( | ||
| [ | ||
| Event( | ||
| process=process, | ||
| event_type=EventType.CREATION, | ||
| file=file, | ||
| host_path=file, | ||
| ) | ||
| ] | ||
| ) | ||
|
|
||
|
|
||
| def test_partial_scan_follows_symlink( | ||
| fact: docker.models.containers.Container, | ||
| fact_config: tuple[dict, str], | ||
| monitored_dir: str, | ||
| ignored_dir: str, | ||
| server: EventServer, | ||
| ): | ||
| """ | ||
| When paths are wildcard-only (e.g. monitored_dir/**/*.txt), | ||
| creating a symlink under monitored_dir should trigger a partial | ||
| scan via prefix matching and start tracking the symlink target. | ||
| """ | ||
| link = os.path.join(monitored_dir, 'link') | ||
| config, config_file = fact_config | ||
| config['paths'].extend([link, f'{link}/**/*.txt']) | ||
| reload_config(fact, config, config_file) | ||
|
|
||
| target = os.path.join(ignored_dir, 'target.txt') | ||
| with open(target, 'w') as f: | ||
| f.write('symlink target') | ||
| os.symlink(os.path.join('..', os.path.basename(ignored_dir)), link) | ||
|
|
||
| process = Process.from_proc() | ||
|
|
||
| server.wait_events( | ||
| [ | ||
| Event( | ||
| process=process, | ||
| event_type=EventType.OPEN, | ||
| file=link, | ||
| host_path=link, | ||
| ) | ||
| ] | ||
| ) | ||
|
|
||
| with open(target, 'w') as f: | ||
| f.write('modified target') | ||
|
|
||
| server.wait_events( | ||
| [ | ||
| Event( | ||
| process=process, | ||
| event_type=EventType.OPEN, | ||
| file=target, | ||
| host_path=os.path.join(link, 'target.txt'), | ||
| ) | ||
| ] | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.