Skip to content

Commit cc00d15

Browse files
committed
[cleaner] Make cleaner concurrent
Draft version adding to the traditional sequential backend also sqlite3 and file based concurrent ones. Closes: #3097 Signed-off-by: Pavel Moravec <pmoravec@redhat.com>
1 parent 0d94ba2 commit cc00d15

11 files changed

Lines changed: 356 additions & 194 deletions

File tree

sos/cleaner/__init__.py

Lines changed: 69 additions & 150 deletions
Large diffs are not rendered by default.

sos/cleaner/archives/__init__.py

Lines changed: 149 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import shutil
1414
import stat
1515
import tarfile
16+
import tempfile
1617
import re
1718

1819
from concurrent.futures import ProcessPoolExecutor
@@ -54,15 +55,15 @@ class SoSObfuscationArchive():
5455
class. All report-level operations should be contained within this class.
5556
"""
5657

57-
file_sub_list = []
58+
files_obfuscated_count = 0
5859
total_sub_count = 0
5960
removed_file_count = 0
6061
type_name = 'undetermined'
6162
description = 'undetermined'
6263
is_nested = False
6364
prep_files = {}
6465

65-
def __init__(self, archive_path, tmpdir):
66+
def __init__(self, archive_path, tmpdir, keep_binary_files):
6667
self.archive_path = archive_path
6768
self.final_archive_path = self.archive_path
6869
self.tmpdir = tmpdir
@@ -74,10 +75,145 @@ def __init__(self, archive_path, tmpdir):
7475
self.is_extracted = False
7576
self._load_self()
7677
self.archive_root = ''
78+
self.keep_binary_files = keep_binary_files
79+
self.parsers = ()
7780
self.log_info(
7881
f"Loaded {self.archive_path} as type {self.description}"
7982
)
8083

84+
def obfuscate_string(self, string_data):
85+
for parser in self.parsers:
86+
try:
87+
string_data = parser.parse_string_for_keys(string_data)
88+
except Exception as err:
89+
self.log_info(f"Error obfuscating string data: {err}")
90+
return string_data
91+
92+
# TODO: merge content to obfuscate_arc_files as that is the only place we call obfuscate_filename ?
93+
def obfuscate_filename(self, short_name, filename):
94+
_ob_short_name = self.obfuscate_string(short_name.split('/')[-1])
95+
_ob_filename = short_name.replace(short_name.split('/')[-1],
96+
_ob_short_name)
97+
98+
if _ob_filename != short_name:
99+
arc_path = filename.split(short_name)[0]
100+
_ob_path = os.path.join(arc_path, _ob_filename)
101+
# ensure that any plugin subdirs that contain obfuscated strings
102+
# get created with obfuscated counterparts
103+
if not os.path.islink(filename):
104+
os.rename(filename, _ob_path)
105+
else:
106+
# generate the obfuscated name of the link target
107+
_target_ob = self.obfuscate_string(os.readlink(filename))
108+
# remove the unobfuscated original symlink first, in case the
109+
# symlink name hasn't changed but the target has
110+
os.remove(filename)
111+
# create the newly obfuscated symlink, pointing to the
112+
# obfuscated target name, which may not exist just yet, but
113+
# when the actual file is obfuscated, will be created
114+
os.symlink(_target_ob, _ob_path)
115+
116+
def set_parsers(self, parsers):
117+
self.parsers = parsers # TODO: include this in initialization / opening the sql connection - or in __init__?
118+
119+
def load_parser_entries(self):
120+
for parser in self.parsers:
121+
parser.load_map_entries()
122+
123+
def unload_parser_entries(self):
124+
for parser in self.parsers:
125+
parser.unload_map_entries()
126+
127+
def obfuscate_line(self, line, parsers=None):
128+
"""Run a line through each of the obfuscation parsers, keeping a
129+
cumulative total of substitutions done on that particular line.
130+
131+
Positional arguments:
132+
133+
:param line str: The raw line as read from the file being
134+
processed
135+
:param parsers: A list of parser objects to obfuscate
136+
with. If None, use all.
137+
138+
Returns the fully obfuscated line and the number of substitutions made
139+
"""
140+
# don't iterate over blank lines, but still write them to the tempfile
141+
# to maintain the same structure when we write a scrubbed file back
142+
count = 0
143+
if not line.strip():
144+
return line, count
145+
if parsers is None:
146+
parsers = self.parsers
147+
for parser in parsers:
148+
try:
149+
line, _count = parser.parse_line(line)
150+
count += _count
151+
except Exception as err:
152+
self.log_debug(f"failed to parse line: {err}", parser.name)
153+
return line, count
154+
155+
def obfuscate_arc_files(self, flist):
156+
for filename in flist:
157+
self.log_debug(f" pid={os.getpid()}: obfuscating {filename}")
158+
try:
159+
short_name = filename.split(self.archive_name + '/')[1]
160+
if self.should_skip_file(short_name):
161+
continue
162+
if (not self.keep_binary_files and
163+
self.should_remove_file(short_name)):
164+
# We reach this case if the option --keep-binary-files
165+
# was not used, and the file is in a list to be removed
166+
self.remove_file(short_name)
167+
continue
168+
if (self.keep_binary_files and
169+
(file_is_binary(fname) or
170+
archive.should_remove_file(short_name))):
171+
# We reach this case if the option --keep-binary-files
172+
# is used. In this case we want to make sure
173+
# the cleaner doesn't try to clean a binary file
174+
continue
175+
if os.path.islink(filename):
176+
# don't run the obfuscation on the link, but on the actual file
177+
# at some other point.
178+
continue
179+
_parsers = [
180+
_p for _p in self.parsers if not
181+
any(
182+
_skip.match(short_name) for _skip in _p.skip_patterns
183+
)
184+
]
185+
if not _parsers:
186+
self.log_debug(
187+
f"Skipping obfuscation of {short_name or filename} due to "
188+
f"matching file skip pattern"
189+
)
190+
continue
191+
self.log_debug(f"Obfuscating {short_name or filename}")
192+
subs = 0
193+
with tempfile.NamedTemporaryFile(mode='w', dir=self.tmpdir) \
194+
as tfile:
195+
with open(filename, 'r', encoding='utf-8',
196+
errors='replace') as fname:
197+
for line in fname:
198+
try:
199+
line, count = self.obfuscate_line(line, _parsers)
200+
subs += count
201+
tfile.write(line)
202+
except Exception as err:
203+
self.log_debug(f"Unable to obfuscate {short_name}:"
204+
f"{err}")
205+
tfile.seek(0)
206+
if subs:
207+
shutil.copyfile(tfile.name, filename)
208+
self.update_sub_count(subs)
209+
210+
self.obfuscate_filename(short_name, filename)
211+
212+
except Exception as err:
213+
self.log_debug(f" pid={os.getpid()}: caught exception on obfuscating file {filename}: {err}")
214+
215+
return (self.files_obfuscated_count, self.total_sub_count, self.removed_file_count)
216+
81217
@classmethod
82218
def check_is_type(cls, arc_path):
83219
"""Check if the archive is a well-known type we directly support"""
@@ -120,14 +256,17 @@ def report_msg(self, msg):
120256
"""Helper to easily format ui messages on a per-report basis"""
121257
self.ui_log.info(f"{self.ui_name + ' :':<50} {msg}")
122258

123-
def _fmt_log_msg(self, msg):
124-
return f"[cleaner:{self.archive_name}] {msg}"
259+
def _fmt_log_msg(self, msg, caller=None):
260+
return f"[cleaner[{self.archive_name}]] {msg}"
261+
262+
def log_debug(self, msg, caller=None):
263+
self.soslog.debug(self._fmt_log_msg(msg, caller))
125264

126-
def log_debug(self, msg):
127-
self.soslog.debug(self._fmt_log_msg(msg))
265+
def log_info(self, msg, caller=None):
266+
self.soslog.info(self._fmt_log_msg(msg, caller))
128267

129-
def log_info(self, msg):
130-
self.soslog.info(self._fmt_log_msg(msg))
268+
def log_error(self, msg, caller=None):
269+
self.soslog.error(self._fmt_log_msg(msg, caller))
131270

132271
def _load_skip_list(self):
133272
"""Provide a list of files and file regexes to skip obfuscation on
@@ -345,11 +484,11 @@ def get_directory_list(self):
345484
dir_list.append(dirname)
346485
return dir_list
347486

348-
def update_sub_count(self, fname, count):
487+
def update_sub_count(self, count):
349488
"""Called when a file has finished being parsed and used to track
350489
total substitutions made and number of files that had changes made
351490
"""
352-
self.file_sub_list.append(fname)
491+
self.files_obfuscated_count += 1
353492
self.total_sub_count += count
354493

355494
def get_file_path(self, fname):

sos/cleaner/mappings/__init__.py

Lines changed: 111 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
#
99
# See the LICENSE file in the source distribution for further information.
1010

11-
import re
12-
13-
from threading import Lock
11+
import re, os, tempfile
12+
import sqlite3, time
13+
from pathlib import Path
1414

1515

1616
class SoSMap():
@@ -28,11 +28,41 @@ class SoSMap():
2828
ignore_short_items = False
2929
match_full_words_only = False
3030

31-
def __init__(self):
32-
self.dataset = {}
31+
def __init__(self, concur_backend, workdir):
32+
# TODO: ALL mappings must be deterministic; NO randomization in hostname or ip(6) mapper!
33+
self.dataset = {} # TODO: mention the child classes can update it as well as add_sanitised_item_to_dataset method, so sqlite or files dont neet to have all counter values stored; e.g. when adding an IP address - sqlite and files approach guarantees same input is added in all processes at the same ordering - it can add a new network to the dataset. So dataset bumps by 2
3334
self._regexes_made = set()
3435
self.compiled_regexes = []
35-
self.lock = Lock()
36+
self.cname = self.__class__.__name__.lower()
37+
# TODO: ensure the directory <workdir> does exist - but only when concur_backend='files'
38+
# TODO: deal with permissions..? IMHO default ones are OK
39+
self.workdir = workdir
40+
self.link_dir = os.path.join(self.workdir, 'cleaner_links', self.cname) #TODO: some better dir name?
41+
self.concur_backend = concur_backend
42+
self.load_entries()
43+
44+
def unload_entries(self):
45+
self.sqlconn = self.sqlcursor = None
46+
47+
def load_entries(self): # originally this was in __init__, but we need to trigger after cloning processes, and also reload content in files or sqlite DB in parent archive class, once children are done
48+
# TODO: call load_entries at beginning of obfuscation (list of) *files*
49+
if self.concur_backend == 'files':
50+
Path(self.link_dir).mkdir(parents=True, exist_ok=True)
51+
self.load_new_entries_from_dir(1) # if there are already stored items, load them first
52+
elif self.concur_backend == 'sql':
53+
Path(os.path.join(self.workdir, 'sqlite_cache')).mkdir(parents=True, exist_ok=True)
54+
self.sqlconn = sqlite3.connect(os.path.join(self.workdir, 'sqlite_cache' , f"{self.cname}.db"), check_same_thread=False) # TODO: better name?
55+
self.sqlconn.execute('pragma journal_mode = wal') # to improve DB locking on frequent concurrent writes
56+
self.sqlconn.execute('pragma temp_store = memory') # 3 lines added from jcastillo's comment
57+
self.sqlconn.execute('pragma synchronous = normal')
58+
self.sqlconn.execute('pragma busy_timeout = 5000')
59+
self.sqlconn.execute('pragma mmap_size = 30000000000') # TODO: cant this be a problem on low performant systems? some kernel limitation behind..?
60+
self.sqlcursor = self.sqlconn.cursor()
61+
self.sqlcursor.execute(f"CREATE TABLE IF NOT EXISTS {self.cname} (counter, item)")
62+
self.sqlcursor.execute(f"CREATE UNIQUE INDEX IF NOT EXISTS ind_{self.cname}_counter_uniq ON {self.cname} (counter)")
63+
self.sqlcursor.execute(f"CREATE UNIQUE INDEX IF NOT EXISTS ind_{self.cname}_item_uniq ON {self.cname} (item)")
64+
self.sqlconn.commit()
65+
self.load_new_entries_from_db(1) # if there are already stored items, load them first
3666

3767
def ignore_item(self, item):
3868
"""Some items need to be completely ignored, for example link-local or
@@ -46,6 +76,42 @@ def ignore_item(self, item):
4676
return True
4777
return False
4878

79+
def add_sanitised_item_to_dataset(self, item):
80+
try:
81+
self.dataset[item] = self.sanitize_item(item)
82+
except Exception as err:
83+
# TODO: add seomthing like self.log_debug(f"Unable to obfuscate {item}: {err}", caller=arc_name)
84+
self.dataset[item] = item
85+
if self.compile_regexes:
86+
self.add_regex_item(item)
87+
88+
def load_new_entries_from_dir(self, counter):
89+
# TODO: this is a performance hack; there can be gaps in counter values as e.g. #14 is an IP address (in file)
90+
# while #15 is its network (in dataset but not in files). So the next file number is 16. The diffs should be
91+
# at most 2, but let be conservative and test next 5 numbers "only".
92+
no_files_cnt = 5
93+
while no_files_cnt > 0:
94+
fname = os.path.join(self.link_dir, f"{counter}")
95+
while os.path.isfile(fname):
96+
no_files_cnt = 5
97+
with open(fname, 'r') as f:
98+
item = f.read()
99+
if not self.dataset.get(item, False):
100+
self.add_sanitised_item_to_dataset(item)
101+
counter += 1
102+
fname = os.path.join(self.link_dir, f"{counter}")
103+
# no next file, but try a new next ones until no_files_cnt==0
104+
no_files_cnt -= 1
105+
counter += 1
106+
107+
def load_new_entries_from_db(self, counter):
108+
if not self.sqlconn:
109+
self.load_entries()
110+
resp = self.sqlcursor.execute(f"SELECT * FROM {self.cname} WHERE counter>={counter} ORDER BY counter ASC")
111+
for _, item in resp.fetchall():
112+
if not self.dataset.get(item, False):
113+
self.add_sanitised_item_to_dataset(item)
114+
49115
def add(self, item):
50116
"""Add a particular item to the map, generating an obfuscated pair
51117
for it.
@@ -56,11 +122,45 @@ def add(self, item):
56122
"""
57123
if self.ignore_item(item):
58124
return item
59-
with self.lock:
60-
self.dataset[item] = self.sanitize_item(item)
61-
if self.compile_regexes:
62-
self.add_regex_item(item)
63-
return self.dataset[item]
125+
126+
if self.concur_backend == 'files':
127+
tmpfile = None
128+
while not self.dataset.get(item, False):
129+
if not tmpfile:
130+
tmpfile = tempfile.NamedTemporaryFile(dir=self.link_dir)
131+
with open(tmpfile.name, 'w') as f:
132+
f.write(item)
133+
try:
134+
counter = len(self.dataset) + 1
135+
os.link(tmpfile.name, os.path.join(self.link_dir, f"{counter}"))
136+
self.add_sanitised_item_to_dataset(item)
137+
except FileExistsError:
138+
self.load_new_entries_from_dir(counter)
139+
140+
elif self.concur_backend == 'sql':
141+
self.load_new_entries_from_db(1)
142+
while not self.dataset.get(item, False):
143+
try:
144+
counter = len(self.dataset) + 1
145+
self.sqlcursor.execute(f"INSERT INTO {self.cname} VALUES ({counter}, '{item}')")
146+
self.sqlconn.commit()
147+
self.add_sanitised_item_to_dataset(item)
148+
except sqlite3.IntegrityError as e:
149+
print(f" {os.getpid()}-{self.cname}: sql conflict for counter={counter} when inserting {item}") # TODO: remove this
150+
self.load_new_entries_from_db(counter)
151+
except sqlite3.OperationalError as e:
152+
print(f" {os.getpid()}-{self.cname}: sql ERROR for counter={counter} when inserting {item}") # TODO: remove this (here we can get a live-lock..)
153+
self.unload_entries()
154+
time.sleep(1)
155+
self.load_entries()
156+
self.load_new_entries_from_db(counter)
157+
except Exception as e:
158+
print(f" {os.getpid()}-{self.cname}: sql generic ERROR {e} for counter={counter} when inserting {item}") # TODO: remove this
159+
raise e
160+
else:
161+
self.add_sanitised_item_to_dataset(item)
162+
163+
return self.dataset[item]
64164

65165
def add_regex_item(self, item):
66166
"""Add an item to the regexes dict and then re-sort the list that the

0 commit comments

Comments
 (0)