Skip to content

Commit c14bc88

Browse files
jaegeraljkppr
andauthored
fix: Allow archived sketches with failed timelines to be unarchived (#3656)
* 1. Resolved the Unarchive Deadlock: * Server-side (`timesketch/api/v1/resources/archive.py`): Modified the unarchive logic to allow sketches with failed or processing timelines to be unarchived. Instead of aborting with a 500 error, the system now logs a warning and proceeds to open all healthy indices. This enables users to recover the sketch and then fix or delete the problematic timelines. * `test_unarchive_sketch_with_failed_timeline`: Updated the docstring to clarify that the test verifies unarchiving is allowed for sketches with failed timelines to enable recovery and subsequent fixing. * `test_unarchive_sketch_with_mixed_states`: Enhanced the docstring to explain that the test confirms robustness against multiple problematic timeline states (fail and processing) during unarchive. --------- Co-authored-by: Janosch <[email protected]>
1 parent 7bd91ce commit c14bc88

File tree

5 files changed

+616
-67
lines changed

5 files changed

+616
-67
lines changed

end_to_end_tests/archive_test.py

Lines changed: 235 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
"""End to end tests for sketch archiving."""
15-
import os
1615
import time
16+
import uuid
17+
import opensearchpy
18+
19+
from timesketch_api_client import search
1720

1821
from . import interface
1922
from . import manager
@@ -26,15 +29,13 @@ class ArchiveTest(interface.BaseEndToEndTest):
2629

2730
def test_archive_sketch_with_failed_timeline(self):
2831
"""Test archiving a sketch with a failed timeline."""
29-
sketch = self.api.create_sketch(name="test-archive-failed")
32+
sketch_name = f"test-archive-failed_{uuid.uuid4().hex}"
33+
sketch = self.api.create_sketch(name=sketch_name)
3034
self.sketch = sketch
3135
self.assertions.assertIsNotNone(sketch)
3236

3337
# This file is known to cause an import failure.
34-
file_path = os.path.join(
35-
os.path.dirname(__file__), "test_data", "invalid_jsonl.jsonl"
36-
)
37-
timeline = self.import_timeline(file_path, sketch=sketch)
38+
timeline = self.import_timeline("invalid_jsonl.jsonl", sketch=sketch)
3839

3940
# Wait for the timeline to fail.
4041
for _ in range(20):
@@ -51,5 +52,233 @@ def test_archive_sketch_with_failed_timeline(self):
5152
index = self.api.get_searchindex(index_name)
5253
self.assertions.assertEqual(index.status, "closed")
5354

55+
def test_unarchive_sketch_with_failed_timeline(self):
56+
"""Test unarchiving a sketch with a failed timeline."""
57+
sketch_name = f"test-unarchive-failed_{uuid.uuid4().hex}"
58+
sketch = self.api.create_sketch(name=sketch_name)
59+
self.sketch = sketch
60+
61+
# This file is known to cause an import failure.
62+
timeline = self.import_timeline("invalid_jsonl.jsonl", sketch=sketch)
63+
64+
# Wait for the timeline to fail.
65+
for _ in range(20):
66+
time.sleep(1)
67+
if timeline.status == "fail":
68+
break
69+
70+
self.assertions.assertEqual(timeline.status, "fail")
71+
sketch.archive()
72+
self.assertions.assertEqual(sketch.status, "archived")
73+
# Unarchive
74+
sketch.unarchive()
75+
self.assertions.assertEqual(sketch.status, "ready")
76+
self.assertions.assertEqual(timeline.status, "fail")
77+
78+
def test_unarchive_sketch_with_missing_index(self):
79+
"""Test unarchiving a sketch where the OpenSearch index is missing."""
80+
sketch_name = f"test-unarchive-missing-index_{uuid.uuid4().hex}"
81+
sketch = self.api.create_sketch(name=sketch_name)
82+
self.sketch = sketch
83+
84+
# Import a valid timeline first
85+
timeline = self.import_timeline("sigma_events.jsonl", sketch=sketch)
86+
index_name = timeline.index_name
87+
88+
sketch.archive()
89+
self.assertions.assertEqual(sketch.status, "archived")
90+
91+
# Manually delete the index from OpenSearch
92+
es = opensearchpy.OpenSearch(
93+
[
94+
{
95+
"host": interface.OPENSEARCH_HOST,
96+
"port": interface.OPENSEARCH_PORT,
97+
}
98+
],
99+
http_compress=True,
100+
)
101+
# Note: Index should be closed now, so delete might need it to be open?
102+
# Or we can delete a closed index.
103+
es.indices.delete(index=index_name)
104+
105+
# Verify it's gone
106+
self.assertions.assertFalse(es.indices.exists(index=index_name))
107+
108+
# Try to unarchive
109+
sketch.unarchive()
110+
111+
# Should succeed (become ready) despite missing index
112+
self.assertions.assertEqual(sketch.status, "ready")
113+
114+
timeline = sketch.list_timelines()[0]
115+
116+
# So ALL timelines in the sketch are set to 'ready', regardless of
117+
# index status! This is because timelines and searchindices are
118+
# separate. SearchIndex status is updated only if in
119+
# successfully_opened_indexes.
120+
121+
self.assertions.assertEqual(timeline.status, "fail")
122+
123+
# Check SearchIndex status
124+
index = self.api.get_searchindex(index_name)
125+
# Should be 'archived' because it wasn't successfully opened.
126+
self.assertions.assertEqual(index.status, "archived")
127+
128+
def test_unarchive_mixed_indices(self):
129+
"""Test unarchiving a sketch with one good and one missing index.
130+
131+
Logic:
132+
1. Create a sketch with two timelines: A (Good) and B (Bad).
133+
2. B's index is manually deleted to simulate failure.
134+
3. Archive the sketch.
135+
4. Unarchive the sketch.
136+
5. Verify that Timeline A remains READY and Timeline B is FAIL.
137+
6. Delete the failed Timeline B.
138+
7. Verify the sketch can be archived and unarchived again successfully.
139+
8. Verify Timeline A is still READY and searchable.
140+
"""
141+
sketch_name = f"test-unarchive-mixed_{uuid.uuid4().hex}"
142+
sketch = self.api.create_sketch(name=sketch_name)
143+
144+
# Import Timeline A (Good)
145+
tl_a = self.import_timeline("sigma_events.jsonl", sketch=sketch)
146+
147+
# Import Timeline B (Bad - will be deleted)
148+
tl_b = self.import_timeline("evtx_part.csv", sketch=sketch)
149+
idx_b_name = tl_b.index_name
150+
151+
sketch.archive()
152+
self.assertions.assertEqual(sketch.status, "archived")
153+
154+
# Delete Index B
155+
es = opensearchpy.OpenSearch(
156+
[
157+
{
158+
"host": interface.OPENSEARCH_HOST,
159+
"port": interface.OPENSEARCH_PORT,
160+
}
161+
],
162+
http_compress=True,
163+
)
164+
es.indices.delete(index=idx_b_name)
165+
166+
# Unarchive
167+
sketch.unarchive()
168+
169+
# Verify Sketch Ready
170+
self.assertions.assertEqual(sketch.status, "ready")
171+
172+
# Refetch timelines
173+
timelines = {t.id: t for t in self.api.get_sketch(sketch.id).list_timelines()}
174+
175+
# Timeline A should be ready
176+
self.assertions.assertEqual(timelines[tl_a.id].status, "ready")
177+
178+
# Timeline B should be fail
179+
self.assertions.assertEqual(timelines[tl_b.id].status, "fail")
180+
181+
# Verify we can search Timeline A
182+
search_client = search.Search(sketch)
183+
search_client.query_filter.update({"indices": [tl_a.id]})
184+
results = search_client.table
185+
self.assertions.assertEqual(len(results), 4)
186+
187+
# Delete the failed timeline B
188+
timelines[tl_b.id].delete()
189+
190+
# Archive again
191+
sketch.archive()
192+
self.assertions.assertEqual(sketch.status, "archived")
193+
194+
# Unarchive again
195+
sketch.unarchive()
196+
self.assertions.assertEqual(sketch.status, "ready")
197+
198+
# Verify Timeline A is still ready and working
199+
final_timelines = self.api.get_sketch(sketch.id).list_timelines()
200+
self.assertions.assertEqual(final_timelines[0].status, "ready")
201+
202+
# Verify search still works
203+
results = search_client.table
204+
self.assertions.assertEqual(len(results), 4)
205+
206+
def test_unarchive_shared_index_missing(self):
207+
"""Test unarchiving when a shared index is missing."""
208+
sketch_name = f"test-unarchive-shared-missing_{uuid.uuid4().hex}"
209+
sketch = self.api.create_sketch(name=sketch_name)
210+
211+
shared_index_name = uuid.uuid4().hex
212+
213+
# Import Timeline A
214+
tl_a = self.import_timeline(
215+
"sigma_events.jsonl", sketch=sketch, index_name=shared_index_name
216+
)
217+
218+
# Import Timeline B into same index
219+
tl_b = self.import_timeline(
220+
"evtx_part.csv", sketch=sketch, index_name=shared_index_name
221+
)
222+
223+
# Verify they share the index
224+
self.assertions.assertEqual(tl_a.index_name, tl_b.index_name)
225+
226+
sketch.archive()
227+
self.assertions.assertEqual(sketch.status, "archived")
228+
229+
# Delete the shared index
230+
es = opensearchpy.OpenSearch(
231+
[
232+
{
233+
"host": interface.OPENSEARCH_HOST,
234+
"port": interface.OPENSEARCH_PORT,
235+
}
236+
],
237+
http_compress=True,
238+
)
239+
es.indices.delete(index=shared_index_name)
240+
241+
# Unarchive
242+
sketch.unarchive()
243+
244+
# Both timelines should be 'fail' because their shared index is gone
245+
timelines = {t.id: t for t in self.api.get_sketch(sketch.id).list_timelines()}
246+
self.assertions.assertEqual(timelines[tl_a.id].status, "fail")
247+
self.assertions.assertEqual(timelines[tl_b.id].status, "fail")
248+
249+
def test_archive_cycle_with_deletion(self):
250+
"""Test archive -> unarchive -> delete timeline -> archive -> unarchive."""
251+
sketch_name = f"test-archive-cycle_{uuid.uuid4().hex}"
252+
sketch = self.api.create_sketch(name=sketch_name)
253+
254+
# 1. Setup: Import Timeline
255+
timeline = self.import_timeline("sigma_events.jsonl", sketch=sketch)
256+
257+
# 2. Cycle 1: Archive -> Unarchive
258+
sketch.archive()
259+
self.assertions.assertEqual(sketch.status, "archived")
260+
261+
sketch.unarchive()
262+
self.assertions.assertEqual(sketch.status, "ready")
263+
264+
# Verify timeline is ready
265+
timeline = sketch.list_timelines()[0]
266+
self.assertions.assertEqual(timeline.status, "ready")
267+
268+
# 3. Delete Timeline
269+
timeline.delete()
270+
271+
# Verify it's gone from list (or deleted status if list includes deleted?)
272+
# sketch.list_timelines() usually excludes deleted by default in client?
273+
# Let's check length.
274+
self.assertions.assertEqual(len(sketch.list_timelines()), 0)
275+
276+
# 4. Cycle 2: Archive -> Unarchive (Empty sketch)
277+
sketch.archive()
278+
self.assertions.assertEqual(sketch.status, "archived")
279+
280+
sketch.unarchive()
281+
self.assertions.assertEqual(sketch.status, "ready")
282+
54283

55284
manager.EndToEndTestManager.register_test(ArchiveTest)

end_to_end_tests/client_test.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import random
2020
import zipfile
2121
import os
22+
import opensearchpy
2223

2324
from timesketch_api_client import search
2425
from timesketch_api_client.error import NotFoundError
@@ -451,6 +452,16 @@ def test_delete_archived_sketch(self):
451452
str(context.exception),
452453
)
453454

455+
# Unarchive
456+
sketch.unarchive()
457+
self.assertions.assertEqual(sketch.status, "ready")
458+
459+
# Now delete
460+
# allow the admin user to read, write and delete the sketch
461+
sketch.add_to_acl(user_list=["admin"], permissions=["read", "write", "delete"])
462+
admin_sketch_instance = self.admin_api.get_sketch(sketch.id)
463+
admin_sketch_instance.delete(force_delete=True)
464+
454465
def test_modify_sketch_name_description(self):
455466
"""Test modifying a sketch's name and description."""
456467
sketch = self.api.create_sketch(
@@ -653,5 +664,37 @@ def test_export_sketch(self):
653664
metadata.get("sketch_name"), self.sketch.name
654665
)
655666

667+
def test_delete_sketch_with_missing_index(self):
668+
"""Test deleting a sketch where the OpenSearch index is missing."""
669+
sketch = self.api.create_sketch(name="test_delete_missing_index")
670+
671+
# Import a timeline
672+
# Just use the filename, import_timeline handles the full path resolution
673+
filename = "sigma_events.jsonl"
674+
timeline = self.import_timeline(filename, sketch=sketch)
675+
index_name = timeline.index_name
676+
# Manually delete the index from OpenSearch
677+
es = opensearchpy.OpenSearch(
678+
[{"host": interface.OPENSEARCH_HOST, "port": interface.OPENSEARCH_PORT}],
679+
http_compress=True,
680+
)
681+
es.indices.delete(index=index_name)
682+
683+
# Switch to admin to force delete (or just delete as owner)
684+
# Owner can delete their own sketch.
685+
686+
# Delete the sketch
687+
# This should succeed despite the missing index (it should just warn
688+
# and continue)
689+
sketch.delete()
690+
691+
# Verify it's gone
692+
sketches = list(self.api.list_sketches())
693+
found = False
694+
for s in sketches:
695+
if s.id == sketch.id:
696+
found = True
697+
self.assertions.assertFalse(found, "Sketch should be deleted")
698+
656699

657700
manager.EndToEndTestManager.register_test(ClientTest)

0 commit comments

Comments
 (0)