1
+import re
2
+from collections import defaultdict
3
+from datetime import datetime
4
+from datetime import timedelta
5
+from typing import Dict
6
from typing import List
7
from typing import Optional
8
+from typing import Tuple
9
10
from loguru import logger
11
+from sqlalchemy import select
12
+from sqlalchemy.ext.asyncio import AsyncSession
13
14
+from app.connectors.wazuh_indexer.models.snapshot_and_restore import SnapshotSchedule
15
+from app.connectors.wazuh_indexer.schema.snapshot_and_restore import (
16
+ CreateSnapshotRequest,
17
+)
18
+from app.connectors.wazuh_indexer.schema.snapshot_and_restore import (
19
+ CreateSnapshotResponse,
20
+)
21
+from app.connectors.wazuh_indexer.schema.snapshot_and_restore import IndexWriteStatus
22
+from app.connectors.wazuh_indexer.schema.snapshot_and_restore import RestoreShardInfo
23
+from app.connectors.wazuh_indexer.schema.snapshot_and_restore import (
24
+ RestoreSnapshotRequest,
25
+)
26
+from app.connectors.wazuh_indexer.schema.snapshot_and_restore import (
27
+ RestoreSnapshotResponse,
28
+)
29
+from app.connectors.wazuh_indexer.schema.snapshot_and_restore import (
30
+ ScheduledSnapshotExecutionResponse,
31
+)
32
+from app.connectors.wazuh_indexer.schema.snapshot_and_restore import SnapshotInfo
33
+from app.connectors.wazuh_indexer.schema.snapshot_and_restore import (
34
+ SnapshotListResponse,
35
+)
36
from app.connectors.wazuh_indexer.schema.snapshot_and_restore import SnapshotRepository
7
-from app.connectors.wazuh_indexer.schema.snapshot_and_restore import SnapshotRepositoryListResponse
37
+from app.connectors.wazuh_indexer.schema.snapshot_and_restore import (
38
+ SnapshotRepositoryListResponse,
39
+)
40
+from app.connectors.wazuh_indexer.schema.snapshot_and_restore import (
41
+ SnapshotScheduleCreate,
42
+)
43
+from app.connectors.wazuh_indexer.schema.snapshot_and_restore import (
44
+ SnapshotScheduleListResponse,
45
+)
46
+from app.connectors.wazuh_indexer.schema.snapshot_and_restore import (
47
+ SnapshotScheduleOperationResponse,
48
+)
49
+from app.connectors.wazuh_indexer.schema.snapshot_and_restore import (
50
+ SnapshotScheduleResponse,
51
+)
52
+from app.connectors.wazuh_indexer.schema.snapshot_and_restore import (
53
+ SnapshotScheduleUpdate,
54
+)
55
from app.connectors.wazuh_indexer.schema.snapshot_and_restore import SnapshotStatus
9
-from app.connectors.wazuh_indexer.schema.snapshot_and_restore import SnapshotStatusResponse
56
+from app.connectors.wazuh_indexer.schema.snapshot_and_restore import (
57
+ SnapshotStatusResponse,
58
+)
59
from app.connectors.wazuh_indexer.utils.universal import create_wazuh_indexer_client
60
+from app.db.db_session import get_db_session
61
+
62
+
63
+def parse_graylog_index_name(index_name: str) -> Tuple[Optional[str], Optional[int]]:
64
+ """
65
+ Parse a Graylog-style index name to extract the base name and index number.
66
+
67
+ Graylog naming convention: {base_name}_{number}
68
+ Examples:
69
+ - wazuh_customer_01 -> ("wazuh_customer", 1)
70
+ - wazuh_00002_307 -> ("wazuh_00002", 307)
71
+ - graylog_0 -> ("graylog", 0)
72
+
73
+ Args:
74
+ index_name: The index name to parse.
75
+
76
+ Returns:
77
+ Tuple of (base_name, index_number) or (None, None) if pattern doesn't match.
78
+ """
79
+ # Match pattern: anything followed by underscore and a number at the end
80
+ pattern = r"^(.+)_(\d+)$"
81
+ match = re.match(pattern, index_name)
82
+
83
+ if match:
84
+ base_name = match.group(1)
85
+ index_number = int(match.group(2))
86
+ return base_name, index_number
87
+
88
+ return None, None
89
+
90
+
91
+def identify_write_indices(index_names: List[str]) -> Dict[str, IndexWriteStatus]:
92
+ """
93
+ Identify which indices are currently being written to based on Graylog naming convention.
94
+
95
+ The index with the highest number for each base name is considered the write index.
96
+
97
+ Args:
98
+ index_names: List of index names to analyze.
99
+
100
+ Returns:
101
+ Dictionary mapping index names to their write status.
102
+ """
103
+ # Group indices by base name
104
+ index_groups: Dict[str, List[Tuple[str, int]]] = defaultdict(list)
105
+
106
+ for index_name in index_names:
107
+ base_name, index_number = parse_graylog_index_name(index_name)
108
+ if base_name is not None and index_number is not None:
109
+ index_groups[base_name].append((index_name, index_number))
110
+
111
+ # Find the highest numbered index for each base name
112
+ write_indices: Dict[str, IndexWriteStatus] = {}
113
+
114
+ for base_name, indices in index_groups.items():
115
+ # Sort by index number to find the highest
116
+ sorted_indices = sorted(indices, key=lambda x: x[1], reverse=True)
117
+ highest_index_name, highest_index_number = sorted_indices[0]
118
+
119
+ # Mark all indices with their write status
120
+ for index_name, index_number in indices:
121
+ is_write_index = index_name == highest_index_name
122
+ write_indices[index_name] = IndexWriteStatus(
123
+ index_name=index_name,
124
+ is_write_index=is_write_index,
125
+ index_number=index_number,
126
+ base_name=base_name,
127
+ )
128
+
129
+ # Handle indices that don't match the Graylog pattern
130
+ for index_name in index_names:
131
+ if index_name not in write_indices:
132
+ write_indices[index_name] = IndexWriteStatus(
133
+ index_name=index_name,
134
+ is_write_index=False, # Assume non-Graylog indices are not write indices
135
+ index_number=None,
136
+ base_name=None,
137
+ )
138
+
139
+ return write_indices
140
+
141
+
142
+async def get_all_indices() -> List[str]:
143
+ """
144
+ Get all index names from the Wazuh Indexer.
145
+
146
+ Returns:
147
+ List of index names.
148
+ """
149
+ try:
150
+ es_client = await create_wazuh_indexer_client("Wazuh-Indexer")
151
+ indices = es_client.indices.get_alias(index="*")
152
+ return list(indices.keys())
153
+ except Exception as e:
154
+ logger.error(f"Failed to get indices: {e}")
155
+ return []
156
+
157
+
158
+async def filter_write_indices(
159
+ requested_indices: Optional[List[str]] = None,
160
+) -> Tuple[List[str], List[str]]:
161
+ """
162
+ Filter out write indices from the requested indices list.
163
+
164
+ Args:
165
+ requested_indices: List of indices to filter. If None, all indices are considered.
166
+
167
+ Returns:
168
+ Tuple of (indices_to_snapshot, skipped_write_indices).
169
+ """
170
+ # Get all indices from the cluster
171
+ all_indices = await get_all_indices()
172
+
173
+ # Identify write indices
174
+ write_status = identify_write_indices(all_indices)
175
+
176
+ # Determine which indices to check
177
+ if requested_indices:
178
+ # Expand wildcards if present
179
+ indices_to_check = []
180
+ for pattern in requested_indices:
181
+ if "*" in pattern:
182
+ # Simple wildcard matching
183
+ regex_pattern = pattern.replace("*", ".*")
184
+ for index_name in all_indices:
185
+ if re.match(f"^{regex_pattern}$", index_name):
186
+ indices_to_check.append(index_name)
187
+ else:
188
+ indices_to_check.append(pattern)
189
+ else:
190
+ indices_to_check = all_indices
191
+
192
+ # Separate write indices from non-write indices
193
+ indices_to_snapshot = []
194
+ skipped_write_indices = []
195
+
196
+ for index_name in indices_to_check:
197
+ status = write_status.get(index_name)
198
+ if status and status.is_write_index:
199
+ skipped_write_indices.append(index_name)
200
+ logger.info(f"Skipping write index: {index_name} " f"(base: {status.base_name}, number: {status.index_number}, "),
201
+ else:
202
+ indices_to_snapshot.append(index_name)
203
+
204
+ return indices_to_snapshot, skipped_write_indices
205
+
206
+
207
+def _schedule_to_response(schedule: SnapshotSchedule) -> SnapshotScheduleResponse:
208
+ """Convert a SnapshotSchedule model to a response model."""
209
+ return SnapshotScheduleResponse(
210
+ id=schedule.id,
211
+ name=schedule.name,
212
+ index_pattern=schedule.index_pattern,
213
+ repository=schedule.repository,
214
+ enabled=schedule.enabled,
215
+ snapshot_prefix=schedule.snapshot_prefix,
216
+ include_global_state=schedule.include_global_state,
217
+ skip_write_indices=schedule.skip_write_indices,
218
+ retention_days=schedule.retention_days,
219
+ last_execution_time=schedule.last_execution_time.isoformat() if schedule.last_execution_time else None,
220
+ last_snapshot_name=schedule.last_snapshot_name,
221
+ last_execution_status=schedule.last_execution_status,
222
+ created_at=schedule.created_at.isoformat(),
223
+ updated_at=schedule.updated_at.isoformat(),
224
+ )
225
226
227
async def list_snapshot_repositories() -> SnapshotRepositoryListResponse:
280
Returns:
281
SnapshotStatusResponse: Response containing snapshot statuses.
282
"""
69
- logger.info(
70
- f"Fetching snapshot status from Wazuh Indexer "
71
- f"(repository={repository}, snapshot={snapshot})",
72
- )
283
+ logger.info(f"Fetching snapshot status from Wazuh Indexer " f"(repository={repository}, snapshot={snapshot}, "),
284
285
try:
286
es_client = await create_wazuh_indexer_client("Wazuh-Indexer")
326
success=False,
327
message=f"Failed to get snapshot status: {str(e)}",
328
)
329
+
330
+
331
+async def list_snapshots(repository: str) -> SnapshotListResponse:
332
+ """
333
+ List all snapshots in a repository.
334
+
335
+ Args:
336
+ repository: Name of the repository to list snapshots from.
337
+
338
+ Returns:
339
+ SnapshotListResponse: Response containing list of snapshots.
340
+ """
341
+ logger.info(f"Fetching snapshots from repository: {repository}")
342
+
343
+ try:
344
+ es_client = await create_wazuh_indexer_client("Wazuh-Indexer")
345
+
346
+ # Get all snapshots in the repository
347
+ response = es_client.snapshot.get(
348
+ repository=repository,
349
+ snapshot="_all",
350
+ ignore_unavailable=True,
351
+ )
352
+
353
+ snapshots: List[SnapshotInfo] = []
354
+
355
+ for snap_data in response.get("snapshots", []):
356
+ snapshot_info = SnapshotInfo(
357
+ snapshot=snap_data.get("snapshot", "unknown"),
358
+ uuid=snap_data.get("uuid"),
359
+ version_id=snap_data.get("version_id"),
360
+ version=snap_data.get("version"),
361
+ indices=snap_data.get("indices", []),
362
+ include_global_state=snap_data.get("include_global_state"),
363
+ state=snap_data.get("state", "unknown"),
364
+ start_time=snap_data.get("start_time"),
365
+ start_time_in_millis=snap_data.get("start_time_in_millis"),
366
+ end_time=snap_data.get("end_time"),
367
+ end_time_in_millis=snap_data.get("end_time_in_millis"),
368
+ duration_in_millis=snap_data.get("duration_in_millis"),
369
+ failures=snap_data.get("failures", []),
370
+ shards=snap_data.get("shards", {}),
371
+ )
372
+ snapshots.append(snapshot_info)
373
+
374
+ logger.info(f"Successfully retrieved {len(snapshots)} snapshots from repository {repository}")
375
+
376
+ return SnapshotListResponse(
377
+ repository=repository,
378
+ snapshots=snapshots,
379
+ success=True,
380
+ message=f"Successfully retrieved {len(snapshots)} snapshots from repository {repository}",
381
+ )
382
+
383
+ except Exception as e:
384
+ logger.error(f"Failed to list snapshots from repository {repository}: {e}")
385
+ return SnapshotListResponse(
386
+ repository=repository,
387
+ snapshots=[],
388
+ success=False,
389
+ message=f"Failed to list snapshots: {str(e)}",
390
+ )
391
+
392
+
393
+async def restore_snapshot(request: RestoreSnapshotRequest) -> RestoreSnapshotResponse:
394
+ """
395
+ Restore a snapshot from a repository.
396
+
397
+ Args:
398
+ request: RestoreSnapshotRequest containing restore parameters.
399
+
400
+ Returns:
401
+ RestoreSnapshotResponse: Response containing restoration details.
402
+ """
403
+ logger.info(
404
+ f"Restoring snapshot {request.snapshot} from repository {request.repository}",
405
+ )
406
+
407
+ try:
408
+ es_client = await create_wazuh_indexer_client("Wazuh-Indexer")
409
+
410
+ # Build the restore body
411
+ body = {}
412
+
413
+ if request.indices:
414
+ body["indices"] = ",".join(request.indices)
415
+
416
+ if request.ignore_unavailable is not None:
417
+ body["ignore_unavailable"] = request.ignore_unavailable
418
+
419
+ if request.include_global_state is not None:
420
+ body["include_global_state"] = request.include_global_state
421
+
422
+ if request.rename_pattern:
423
+ body["rename_pattern"] = request.rename_pattern
424
+
425
+ if request.rename_replacement:
426
+ body["rename_replacement"] = request.rename_replacement
427
+
428
+ if request.include_aliases is not None:
429
+ body["include_aliases"] = request.include_aliases
430
+
431
+ if request.partial is not None:
432
+ body["partial"] = request.partial
433
+
434
+ # Restore the snapshot
435
+ response = es_client.snapshot.restore(
436
+ repository=request.repository,
437
+ snapshot=request.snapshot,
438
+ body=body if body else None,
439
+ wait_for_completion=False,
440
+ )
441
+
442
+ # Parse the response
443
+ snapshot_data = response.get("snapshot", {})
444
+ shards_data = snapshot_data.get("shards", {})
445
+
446
+ shards_info = RestoreShardInfo(
447
+ total=shards_data.get("total", 0),
448
+ failed=shards_data.get("failed", 0),
449
+ successful=shards_data.get("successful", 0),
450
+ )
451
+
452
+ restored_indices = snapshot_data.get("indices", [])
453
+
454
+ logger.info(f"Successfully initiated restore of snapshot {request.snapshot} ({len(restored_indices)} indices)")
455
+
456
+ return RestoreSnapshotResponse(
457
+ snapshot=request.snapshot,
458
+ repository=request.repository,
459
+ indices=restored_indices,
460
+ shards=shards_info,
461
+ success=True,
462
+ message=f"Successfully initiated restore of snapshot {request.snapshot}",
463
+ )
464
+
465
+ except Exception as e:
466
+ logger.error(f"Failed to restore snapshot {request.snapshot}: {e}")
467
+ return RestoreSnapshotResponse(
468
+ snapshot=request.snapshot,
469
+ repository=request.repository,
470
+ indices=[],
471
+ shards=RestoreShardInfo(total=0, failed=0, successful=0),
472
+ success=False,
473
+ message=f"Failed to restore snapshot: {str(e)}",
474
+ )
475
+
476
+
477
+async def create_snapshot(request: CreateSnapshotRequest) -> CreateSnapshotResponse:
478
+ """
479
+ Create a snapshot in a repository.
480
+
481
+ Args:
482
+ request: CreateSnapshotRequest containing snapshot parameters.
483
+
484
+ Returns:
485
+ CreateSnapshotResponse: Response containing snapshot creation details.
486
+ """
487
+ logger.info(
488
+ f"Creating snapshot {request.snapshot} in repository {request.repository}",
489
+ )
490
+
491
+ try:
492
+ es_client = await create_wazuh_indexer_client("Wazuh-Indexer")
493
+
494
+ # Filter out write indices if requested
495
+ skipped_write_indices = []
496
+ indices_to_snapshot = request.indices
497
+
498
+ if request.skip_write_indices:
499
+ indices_to_snapshot, skipped_write_indices = await filter_write_indices(
500
+ requested_indices=request.indices,
501
+ )
502
+
503
+ if skipped_write_indices:
504
+ logger.info(
505
+ f"Skipping {len(skipped_write_indices)} write indices: {skipped_write_indices}",
506
+ )
507
+
508
+ if not indices_to_snapshot:
509
+ logger.warning("No indices to snapshot after filtering write indices")
510
+ return CreateSnapshotResponse(
511
+ snapshot=request.snapshot,
512
+ repository=request.repository,
513
+ uuid=None,
514
+ state=None,
515
+ indices=[],
516
+ skipped_write_indices=skipped_write_indices,
517
+ shards=None,
518
+ accepted=False,
519
+ success=False,
520
+ message="No indices to snapshot - all requested indices are currently being written to",
521
+ )
522
+
523
+ # Build the snapshot body
524
+ body = {}
525
+
526
+ if indices_to_snapshot:
527
+ body["indices"] = ",".join(indices_to_snapshot)
528
+
529
+ if request.ignore_unavailable is not None:
530
+ body["ignore_unavailable"] = request.ignore_unavailable
531
+
532
+ if request.include_global_state is not None:
533
+ body["include_global_state"] = request.include_global_state
534
+
535
+ if request.partial is not None:
536
+ body["partial"] = request.partial
537
+
538
+ if request.metadata:
539
+ # Add skipped indices to metadata for reference
540
+ metadata = request.metadata.copy()
541
+ if skipped_write_indices:
542
+ metadata["skipped_write_indices"] = skipped_write_indices
543
+ body["metadata"] = metadata
544
+ elif skipped_write_indices:
545
+ body["metadata"] = {"skipped_write_indices": skipped_write_indices}
546
+
547
+ # Create the snapshot
548
+ response = es_client.snapshot.create(
549
+ repository=request.repository,
550
+ snapshot=request.snapshot,
551
+ body=body if body else None,
552
+ wait_for_completion=request.wait_for_completion or False,
553
+ )
554
+
555
+ # Parse the response based on whether we waited for completion
556
+ if request.wait_for_completion:
557
+ snapshot_data = response.get("snapshot", {})
558
+ shards_data = snapshot_data.get("shards", {})
559
+
560
+ shards_info = RestoreShardInfo(
561
+ total=shards_data.get("total", 0),
562
+ failed=shards_data.get("failed", 0),
563
+ successful=shards_data.get("successful", 0),
564
+ )
565
+
566
+ message = f"Successfully created snapshot {request.snapshot}"
567
+ if skipped_write_indices:
568
+ message += f" (skipped {len(skipped_write_indices)} write indices)"
569
+
570
+ logger.info(message)
571
+
572
+ return CreateSnapshotResponse(
573
+ snapshot=snapshot_data.get("snapshot", request.snapshot),
574
+ repository=request.repository,
575
+ uuid=snapshot_data.get("uuid"),
576
+ state=snapshot_data.get("state"),
577
+ indices=snapshot_data.get("indices", []),
578
+ skipped_write_indices=skipped_write_indices,
579
+ shards=shards_info,
580
+ accepted=True,
581
+ success=True,
582
+ message=message,
583
+ )
584
+ else:
585
+ # When not waiting, we get an accepted response
586
+ accepted = response.get("accepted", False)
587
+
588
+ message = f"Snapshot {request.snapshot} creation initiated"
589
+ if skipped_write_indices:
590
+ message += f" (skipped {len(skipped_write_indices)} write indices)"
591
+
592
+ logger.info(message)
593
+
594
+ return CreateSnapshotResponse(
595
+ snapshot=request.snapshot,
596
+ repository=request.repository,
597
+ uuid=None,
598
+ state="IN_PROGRESS",
599
+ indices=indices_to_snapshot or [],
600
+ skipped_write_indices=skipped_write_indices,
601
+ shards=None,
602
+ accepted=accepted,
603
+ success=accepted,
604
+ message=message if accepted else "Snapshot request was not accepted",
605
+ )
606
+
607
+ except Exception as e:
608
+ logger.error(f"Failed to create snapshot {request.snapshot}: {e}")
609
+ return CreateSnapshotResponse(
610
+ snapshot=request.snapshot,
611
+ repository=request.repository,
612
+ uuid=None,
613
+ state=None,
614
+ indices=[],
615
+ skipped_write_indices=[],
616
+ shards=None,
617
+ accepted=False,
618
+ success=False,
619
+ message=f"Failed to create snapshot: {str(e)}",
620
+ )
621
+
622
+
623
+async def create_snapshot_schedule(
624
+ request: SnapshotScheduleCreate,
625
+ session: AsyncSession,
626
+) -> SnapshotScheduleOperationResponse:
627
+ """
628
+ Create a new snapshot schedule.
629
+
630
+ Args:
631
+ request: SnapshotScheduleCreate containing schedule parameters.
632
+ session: Database session.
633
+
634
+ Returns:
635
+ SnapshotScheduleOperationResponse: Response containing the created schedule.
636
+ """
637
+ logger.info(f"Creating snapshot schedule: {request.name}")
638
+
639
+ try:
640
+ schedule = SnapshotSchedule(
641
+ name=request.name,
642
+ index_pattern=request.index_pattern,
643
+ repository=request.repository,
644
+ enabled=request.enabled if request.enabled is not None else True,
645
+ snapshot_prefix=request.snapshot_prefix or "scheduled",
646
+ include_global_state=request.include_global_state if request.include_global_state is not None else False,
647
+ skip_write_indices=request.skip_write_indices if request.skip_write_indices is not None else True,
648
+ retention_days=request.retention_days,
649
+ )
650
+
651
+ session.add(schedule)
652
+ await session.commit()
653
+ await session.refresh(schedule)
654
+
655
+ logger.info(f"Successfully created snapshot schedule: {schedule.name} (ID: {schedule.id},)")
656
+
657
+ return SnapshotScheduleOperationResponse(
658
+ schedule=_schedule_to_response(schedule),
659
+ success=True,
660
+ message=f"Successfully created snapshot schedule: {schedule.name}",
661
+ )
662
+
663
+ except Exception as e:
664
+ logger.error(f"Failed to create snapshot schedule: {e}")
665
+ await session.rollback()
666
+ return SnapshotScheduleOperationResponse(
667
+ schedule=None,
668
+ success=False,
669
+ message=f"Failed to create snapshot schedule: {str(e)}",
670
+ )
671
+
672
+
673
+async def list_snapshot_schedules(
674
+ session: AsyncSession,
675
+ enabled_only: bool = False,
676
+) -> SnapshotScheduleListResponse:
677
+ """
678
+ List all snapshot schedules.
679
+
680
+ Args:
681
+ session: Database session.
682
+ enabled_only: If True, only return enabled schedules.
683
+
684
+ Returns:
685
+ SnapshotScheduleListResponse: Response containing list of schedules.
686
+ """
687
+ logger.info("Fetching snapshot schedules")
688
+
689
+ try:
690
+ query = select(SnapshotSchedule)
691
+ if enabled_only:
692
+ query = query.where(SnapshotSchedule.enabled == True)
693
+
694
+ result = await session.execute(query)
695
+ schedules = result.scalars().all()
696
+
697
+ schedule_responses = [_schedule_to_response(s) for s in schedules]
698
+
699
+ logger.info(f"Successfully retrieved {len(schedule_responses)} snapshot schedules")
700
+
701
+ return SnapshotScheduleListResponse(
702
+ schedules=schedule_responses,
703
+ success=True,
704
+ message=f"Successfully retrieved {len(schedule_responses)} snapshot schedules",
705
+ )
706
+
707
+ except Exception as e:
708
+ logger.error(f"Failed to list snapshot schedules: {e}")
709
+ return SnapshotScheduleListResponse(
710
+ schedules=[],
711
+ success=False,
712
+ message=f"Failed to list snapshot schedules: {str(e)}",
713
+ )
714
+
715
+
716
+async def get_snapshot_schedule(
717
+ schedule_id: int,
718
+ session: AsyncSession,
719
+) -> SnapshotScheduleOperationResponse:
720
+ """
721
+ Get a snapshot schedule by ID.
722
+
723
+ Args:
724
+ schedule_id: ID of the schedule to retrieve.
725
+ session: Database session.
726
+
727
+ Returns:
728
+ SnapshotScheduleOperationResponse: Response containing the schedule.
729
+ """
730
+ logger.info(f"Fetching snapshot schedule ID: {schedule_id}")
731
+
732
+ try:
733
+ result = await session.execute(select(SnapshotSchedule).where(SnapshotSchedule.id == schedule_id))
734
+ schedule = result.scalar_one_or_none()
735
+
736
+ if not schedule:
737
+ return SnapshotScheduleOperationResponse(
738
+ schedule=None,
739
+ success=False,
740
+ message=f"Snapshot schedule with ID {schedule_id} not found",
741
+ )
742
+
743
+ return SnapshotScheduleOperationResponse(
744
+ schedule=_schedule_to_response(schedule),
745
+ success=True,
746
+ message=f"Successfully retrieved snapshot schedule: {schedule.name}",
747
+ )
748
+
749
+ except Exception as e:
750
+ logger.error(f"Failed to get snapshot schedule: {e}")
751
+ return SnapshotScheduleOperationResponse(
752
+ schedule=None,
753
+ success=False,
754
+ message=f"Failed to get snapshot schedule: {str(e)}",
755
+ )
756
+
757
+
758
+async def update_snapshot_schedule(
759
+ schedule_id: int,
760
+ request: SnapshotScheduleUpdate,
761
+ session: AsyncSession,
762
+) -> SnapshotScheduleOperationResponse:
763
+ """
764
+ Update a snapshot schedule.
765
+
766
+ Args:
767
+ schedule_id: ID of the schedule to update.
768
+ request: SnapshotScheduleUpdate containing fields to update.
769
+ session: Database session.
770
+
771
+ Returns:
772
+ SnapshotScheduleOperationResponse: Response containing the updated schedule.
773
+ """
774
+ logger.info(f"Updating snapshot schedule ID: {schedule_id}")
775
+
776
+ try:
777
+ result = await session.execute(select(SnapshotSchedule).where(SnapshotSchedule.id == schedule_id))
778
+ schedule = result.scalar_one_or_none()
779
+
780
+ if not schedule:
781
+ return SnapshotScheduleOperationResponse(
782
+ schedule=None,
783
+ success=False,
784
+ message=f"Snapshot schedule with ID {schedule_id} not found",
785
+ )
786
+
787
+ # Update fields if provided
788
+ if request.name is not None:
789
+ schedule.name = request.name
790
+ if request.index_pattern is not None:
791
+ schedule.index_pattern = request.index_pattern
792
+ if request.repository is not None:
793
+ schedule.repository = request.repository
794
+ if request.enabled is not None:
795
+ schedule.enabled = request.enabled
796
+ if request.snapshot_prefix is not None:
797
+ schedule.snapshot_prefix = request.snapshot_prefix
798
+ if request.include_global_state is not None:
799
+ schedule.include_global_state = request.include_global_state
800
+ if request.skip_write_indices is not None:
801
+ schedule.skip_write_indices = request.skip_write_indices
802
+ if request.retention_days is not None:
803
+ schedule.retention_days = request.retention_days
804
+
805
+ schedule.updated_at = datetime.utcnow()
806
+
807
+ await session.commit()
808
+ await session.refresh(schedule)
809
+
810
+ logger.info(f"Successfully updated snapshot schedule: {schedule.name}")
811
+
812
+ return SnapshotScheduleOperationResponse(
813
+ schedule=_schedule_to_response(schedule),
814
+ success=True,
815
+ message=f"Successfully updated snapshot schedule: {schedule.name}",
816
+ )
817
+
818
+ except Exception as e:
819
+ logger.error(f"Failed to update snapshot schedule: {e}")
820
+ await session.rollback()
821
+ return SnapshotScheduleOperationResponse(
822
+ schedule=None,
823
+ success=False,
824
+ message=f"Failed to update snapshot schedule: {str(e)}",
825
+ )
826
+
827
+
828
+async def delete_snapshot_schedule(
829
+ schedule_id: int,
830
+ session: AsyncSession,
831
+) -> SnapshotScheduleOperationResponse:
832
+ """
833
+ Delete a snapshot schedule.
834
+
835
+ Args:
836
+ schedule_id: ID of the schedule to delete.
837
+ session: Database session.
838
+
839
+ Returns:
840
+ SnapshotScheduleOperationResponse: Response indicating success or failure.
841
+ """
842
+ logger.info(f"Deleting snapshot schedule ID: {schedule_id}")
843
+
844
+ try:
845
+ result = await session.execute(select(SnapshotSchedule).where(SnapshotSchedule.id == schedule_id))
846
+ schedule = result.scalar_one_or_none()
847
+
848
+ if not schedule:
849
+ return SnapshotScheduleOperationResponse(
850
+ schedule=None,
851
+ success=False,
852
+ message=f"Snapshot schedule with ID {schedule_id} not found",
853
+ )
854
+
855
+ schedule_name = schedule.name
856
+ await session.delete(schedule)
857
+ await session.commit()
858
+
859
+ logger.info(f"Successfully deleted snapshot schedule: {schedule_name}")
860
+
861
+ return SnapshotScheduleOperationResponse(
862
+ schedule=None,
863
+ success=True,
864
+ message=f"Successfully deleted snapshot schedule: {schedule_name}",
865
+ )
866
+
867
+ except Exception as e:
868
+ logger.error(f"Failed to delete snapshot schedule: {e}")
869
+ await session.rollback()
870
+ return SnapshotScheduleOperationResponse(
871
+ schedule=None,
872
+ success=False,
873
+ message=f"Failed to delete snapshot schedule: {str(e)}",
874
+ )
875
+
876
+
877
+async def get_snapshotted_indices_for_schedule(
878
+ schedule: SnapshotSchedule,
879
+) -> set[str]:
880
+ """
881
+ Get all indices that have already been snapshotted for a given schedule.
882
+
883
+ Args:
884
+ schedule: The snapshot schedule to check.
885
+
886
+ Returns:
887
+ Set of index names that have already been snapshotted.
888
+ """
889
+ logger.info(f"Fetching previously snapshotted indices for schedule: {schedule.name}")
890
+
891
+ snapshotted_indices: set[str] = set()
892
+
893
+ try:
894
+ es_client = await create_wazuh_indexer_client("Wazuh-Indexer")
895
+
896
+ # List all snapshots in the repository
897
+ response = es_client.snapshot.get(
898
+ repository=schedule.repository,
899
+ snapshot="_all",
900
+ ignore_unavailable=True,
901
+ )
902
+
903
+ # Build the prefix pattern for this schedule's snapshots
904
+ prefix = f"{schedule.snapshot_prefix}_{schedule.name}_".lower().replace(" ", "_")
905
+
906
+ for snap_data in response.get("snapshots", []):
907
+ snapshot_name = snap_data.get("snapshot", "")
908
+
909
+ # Only consider snapshots created by this schedule
910
+ if snapshot_name.startswith(prefix):
911
+ indices = snap_data.get("indices", [])
912
+ snapshotted_indices.update(indices)
913
+
914
+ logger.info(f"Found {len(snapshotted_indices)} previously snapshotted indices " f"for schedule {schedule.name}")
915
+
916
+ return snapshotted_indices
917
+
918
+ except Exception as e:
919
+ logger.error(f"Failed to get snapshotted indices for schedule {schedule.name}: {e}")
920
+ return set()
921
+
922
+
923
+async def get_indices_needing_snapshot(
924
+ schedule: SnapshotSchedule,
925
+) -> tuple[list[str], list[str], list[str]]:
926
+ """
927
+ Determine which indices need to be snapshotted based on the schedule's index pattern.
928
+
929
+ This function:
930
+ 1. Gets all indices matching the schedule's pattern
931
+ 2. Filters out write indices (if configured)
932
+ 3. Filters out indices that have already been snapshotted
933
+
934
+ Args:
935
+ schedule: The snapshot schedule.
936
+
937
+ Returns:
938
+ Tuple of (indices_to_snapshot, skipped_write_indices, already_snapshotted_indices).
939
+ """
940
+ logger.info(f"Determining indices needing snapshot for schedule: {schedule.name}")
941
+
942
+ # Get all indices from the cluster
943
+ all_indices = await get_all_indices()
944
+
945
+ # Filter indices matching the schedule's pattern
946
+ matching_indices = []
947
+ pattern = schedule.index_pattern
948
+ if "*" in pattern:
949
+ regex_pattern = pattern.replace("*", ".*")
950
+ for index_name in all_indices:
951
+ if re.match(f"^{regex_pattern}$", index_name):
952
+ matching_indices.append(index_name)
953
+ else:
954
+ if pattern in all_indices:
955
+ matching_indices.append(pattern)
956
+
957
+ logger.info(f"Found {len(matching_indices)} indices matching pattern '{pattern}'")
958
+
959
+ # Identify write indices
960
+ write_status = identify_write_indices(all_indices)
961
+
962
+ # Get previously snapshotted indices
963
+ previously_snapshotted = await get_snapshotted_indices_for_schedule(schedule)
964
+
965
+ # Categorize indices
966
+ indices_to_snapshot = []
967
+ skipped_write_indices = []
968
+ already_snapshotted_indices = []
969
+
970
+ for index_name in matching_indices:
971
+ status = write_status.get(index_name)
972
+
973
+ # Check if it's a write index
974
+ if schedule.skip_write_indices and status and status.is_write_index:
975
+ skipped_write_indices.append(index_name)
976
+ logger.debug(f"Skipping write index: {index_name}")
977
+ continue
978
+
979
+ # Check if already snapshotted
980
+ if index_name in previously_snapshotted:
981
+ already_snapshotted_indices.append(index_name)
982
+ logger.debug(f"Skipping already snapshotted index: {index_name}")
983
+ continue
984
+
985
+ # This index needs to be snapshotted
986
+ indices_to_snapshot.append(index_name)
987
+
988
+ logger.info(
989
+ f"Schedule {schedule.name}: "
990
+ f"{len(indices_to_snapshot)} to snapshot, "
991
+ f"{len(skipped_write_indices)} write indices skipped, "
992
+ f"{len(already_snapshotted_indices)} already snapshotted",
993
+ )
994
+
995
+ return indices_to_snapshot, skipped_write_indices, already_snapshotted_indices
996
+
997
+
998
+async def execute_snapshot_schedule(
999
+ schedule: SnapshotSchedule,
1000
+ session: AsyncSession,
1001
+) -> ScheduledSnapshotExecutionResponse:
1002
+ """
1003
+ Execute a single snapshot schedule.
1004
+
1005
+ Args:
1006
+ schedule: The schedule to execute.
1007
+ session: Database session.
1008
+
1009
+ Returns:
1010
+ ScheduledSnapshotExecutionResponse: Response containing execution details.
1011
+ """
1012
+ logger.info(f"Executing snapshot schedule: {schedule.name} (ID: {schedule.id})")
1013
+
1014
+ try:
1015
+ # Determine which indices need to be snapshotted
1016
+ indices_to_snapshot, skipped_write_indices, already_snapshotted = await get_indices_needing_snapshot(schedule)
1017
+
1018
+ # If no new indices to snapshot, skip this execution
1019
+ if not indices_to_snapshot:
1020
+ message = "No new indices to snapshot"
1021
+ if skipped_write_indices:
1022
+ message += f" ({len(skipped_write_indices)} write indices skipped)"
1023
+ if already_snapshotted:
1024
+ message += f" ({len(already_snapshotted)} already snapshotted)"
1025
+
1026
+ logger.info(f"Schedule {schedule.name}: {message}")
1027
+
1028
+ # Update schedule with execution results
1029
+ schedule.last_execution_time = datetime.utcnow()
1030
+ schedule.last_execution_status = f"SKIPPED: {message}"
1031
+ schedule.updated_at = datetime.utcnow()
1032
+
1033
+ await session.commit()
1034
+
1035
+ return ScheduledSnapshotExecutionResponse(
1036
+ schedule_id=schedule.id,
1037
+ schedule_name=schedule.name,
1038
+ snapshot_name=None,
1039
+ indices_snapshotted=[],
1040
+ skipped_write_indices=skipped_write_indices,
1041
+ success=True, # Not a failure, just nothing to do
1042
+ message=message,
1043
+ )
1044
+
1045
+ # Generate snapshot name with timestamp
1046
+ timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
1047
+ snapshot_name = f"{schedule.snapshot_prefix}_{schedule.name}_{timestamp}".lower().replace(" ", "_")
1048
+
1049
+ # Create the snapshot request with specific indices (not patterns)
1050
+ request = CreateSnapshotRequest(
1051
+ repository=schedule.repository,
1052
+ snapshot=snapshot_name,
1053
+ indices=indices_to_snapshot, # Use the specific indices, not the pattern
1054
+ ignore_unavailable=True,
1055
+ include_global_state=schedule.include_global_state,
1056
+ partial=False,
1057
+ wait_for_completion=False,
1058
+ skip_write_indices=False, # Already filtered above
1059
+ metadata={
1060
+ "schedule_id": schedule.id,
1061
+ "schedule_name": schedule.name,
1062
+ "created_by": "scheduled_job",
1063
+ "skipped_write_indices": skipped_write_indices,
1064
+ "already_snapshotted_count": len(already_snapshotted),
1065
+ },
1066
+ )
1067
+
1068
+ # Execute the snapshot
1069
+ response = await create_snapshot(request)
1070
+
1071
+ # Update schedule with execution results
1072
+ schedule.last_execution_time = datetime.utcnow()
1073
+ schedule.last_snapshot_name = snapshot_name if response.success else None
1074
+ schedule.last_execution_status = "SUCCESS" if response.success else f"FAILED: {response.message}"
1075
+ schedule.updated_at = datetime.utcnow()
1076
+
1077
+ await session.commit()
1078
+
1079
+ if response.success:
1080
+ logger.info(
1081
+ f"Successfully executed schedule {schedule.name}: "
1082
+ f"snapshot={snapshot_name}, "
1083
+ f"indices={len(indices_to_snapshot)}, "
1084
+ f"skipped_write={len(skipped_write_indices)}, "
1085
+ f"already_snapshotted={len(already_snapshotted)}",
1086
+ )
1087
+ else:
1088
+ logger.error(f"Failed to execute schedule {schedule.name}: {response.message}")
1089
+
1090
+ return ScheduledSnapshotExecutionResponse(
1091
+ schedule_id=schedule.id,
1092
+ schedule_name=schedule.name,
1093
+ snapshot_name=snapshot_name if response.success else None,
1094
+ indices_snapshotted=indices_to_snapshot if response.success else [],
1095
+ skipped_write_indices=skipped_write_indices,
1096
+ success=response.success,
1097
+ message=response.message,
1098
+ )
1099
+
1100
+ except Exception as e:
1101
+ logger.error(f"Failed to execute snapshot schedule {schedule.name}: {e}")
1102
+
1103
+ # Update schedule with failure status
1104
+ schedule.last_execution_time = datetime.utcnow()
1105
+ schedule.last_execution_status = f"FAILED: {str(e)}"
1106
+ schedule.updated_at = datetime.utcnow()
1107
+
1108
+ try:
1109
+ await session.commit()
1110
+ except Exception:
1111
+ pass
1112
+
1113
+ return ScheduledSnapshotExecutionResponse(
1114
+ schedule_id=schedule.id,
1115
+ schedule_name=schedule.name,
1116
+ snapshot_name=None,
1117
+ indices_snapshotted=[],
1118
+ skipped_write_indices=[],
1119
+ success=False,
1120
+ message=f"Failed to execute snapshot schedule: {str(e)}",
1121
+ )
1122
+
1123
+
1124
+async def execute_all_enabled_schedules() -> List[ScheduledSnapshotExecutionResponse]:
1125
+ """
1126
+ Execute all enabled snapshot schedules.
1127
+
1128
+ Returns:
1129
+ List of execution responses for each schedule.
1130
+ """
1131
+ logger.info("Executing all enabled snapshot schedules")
1132
+
1133
+ results: List[ScheduledSnapshotExecutionResponse] = []
1134
+
1135
+ async with get_db_session() as session:
1136
+ # Get all enabled schedules
1137
+ result = await session.execute(select(SnapshotSchedule).where(SnapshotSchedule.enabled == True))
1138
+ schedules = result.scalars().all()
1139
+
1140
+ logger.info(f"Found {len(schedules)} enabled snapshot schedules")
1141
+
1142
+ for schedule in schedules:
1143
+ execution_result = await execute_snapshot_schedule(schedule, session)
1144
+ results.append(execution_result)
1145
+
1146
+ successful = sum(1 for r in results if r.success)
1147
+ failed = len(results) - successful
1148
+ logger.info(f"Completed scheduled snapshots: {successful} successful, {failed} failed")
1149
+
1150
+ return results
1151
+
1152
+
1153
+async def cleanup_old_snapshots(
1154
+ schedule: SnapshotSchedule,
1155
+) -> Dict[str, any]:
1156
+ """
1157
+ Clean up old snapshots based on retention policy.
1158
+
1159
+ Args:
1160
+ schedule: The schedule with retention settings.
1161
+
1162
+ Returns:
1163
+ Dictionary with cleanup results.
1164
+ """
1165
+ if not schedule.retention_days:
1166
+ return {"deleted": 0, "message": "No retention policy configured"}
1167
+
1168
+ logger.info(f"Cleaning up snapshots for schedule {schedule.name} " f"(retention: {schedule.retention_days} days)")
1169
+
1170
+ try:
1171
+ es_client = await create_wazuh_indexer_client("Wazuh-Indexer")
1172
+
1173
+ # List snapshots in the repository
1174
+ response = es_client.snapshot.get(
1175
+ repository=schedule.repository,
1176
+ snapshot="_all",
1177
+ ignore_unavailable=True,
1178
+ )
1179
+
1180
+ snapshots_to_delete = []
1181
+ cutoff_time = datetime.utcnow() - timedelta(days=schedule.retention_days)
1182
+ cutoff_millis = int(cutoff_time.timestamp() * 1000)
1183
+
1184
+ # Find snapshots matching this schedule's prefix that are older than retention
1185
+ prefix = f"{schedule.snapshot_prefix}_{schedule.name}_".lower().replace(" ", "_")
1186
+
1187
+ for snap_data in response.get("snapshots", []):
1188
+ snapshot_name = snap_data.get("snapshot", "")
1189
+ end_time_millis = snap_data.get("end_time_in_millis", 0)
1190
+
1191
+ if snapshot_name.startswith(prefix) and end_time_millis < cutoff_millis:
1192
+ snapshots_to_delete.append(snapshot_name)
1193
+
1194
+ # Delete old snapshots
1195
+ deleted_count = 0
1196
+ for snapshot_name in snapshots_to_delete:
1197
+ try:
1198
+ es_client.snapshot.delete(
1199
+ repository=schedule.repository,
1200
+ snapshot=snapshot_name,
1201
+ )
1202
+ deleted_count += 1
1203
+ logger.info(f"Deleted old snapshot: {snapshot_name}")
1204
+ except Exception as e:
1205
+ logger.error(f"Failed to delete snapshot {snapshot_name}: {e}")
1206
+
1207
+ return {
1208
+ "deleted": deleted_count,
1209
+ "message": f"Deleted {deleted_count} snapshots older than {schedule.retention_days} days",
1210
+ }
1211
+
1212
+ except Exception as e:
1213
+ logger.error(f"Failed to cleanup snapshots for schedule {schedule.name}: {e}")
1214
+ return {"deleted": 0, "message": f"Cleanup failed: {str(e)}"}