dwipe 2.0.0__py3-none-any.whl → 2.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dwipe/DeviceInfo.py +291 -59
- dwipe/DiskWipe.py +497 -172
- dwipe/DrivePreChecker.py +90 -0
- dwipe/FirmwareWipeTask.py +370 -0
- dwipe/LsblkMonitor.py +124 -0
- dwipe/PersistentState.py +28 -18
- dwipe/Prereqs.py +84 -0
- dwipe/StructuredLogger.py +643 -0
- dwipe/ToolManager.py +618 -0
- dwipe/Utils.py +108 -0
- dwipe/VerifyTask.py +410 -0
- dwipe/WipeJob.py +613 -165
- dwipe/WipeTask.py +148 -0
- dwipe/WriteTask.py +402 -0
- dwipe/main.py +14 -9
- {dwipe-2.0.0.dist-info → dwipe-2.0.2.dist-info}/METADATA +69 -30
- dwipe-2.0.2.dist-info/RECORD +21 -0
- dwipe/WipeJobFuture.py +0 -245
- dwipe-2.0.0.dist-info/RECORD +0 -13
- {dwipe-2.0.0.dist-info → dwipe-2.0.2.dist-info}/WHEEL +0 -0
- {dwipe-2.0.0.dist-info → dwipe-2.0.2.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.0.dist-info → dwipe-2.0.2.dist-info}/licenses/LICENSE +0 -0
dwipe/WipeTask.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WipeTask - Abstract base class for wipe/verify operations
|
|
3
|
+
|
|
4
|
+
Defines the interface and shared state for all task types.
|
|
5
|
+
"""
|
|
6
|
+
# pylint: disable=broad-exception-raised,broad-exception-caught
|
|
7
|
+
import time
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
|
|
10
|
+
from .Utils import Utils
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WipeTask:
|
|
14
|
+
"""Abstract base class for wipe/verify operations
|
|
15
|
+
|
|
16
|
+
Defines the interface that all tasks must implement:
|
|
17
|
+
- run_task(): Execute the task (blocking, runs in thread)
|
|
18
|
+
- get_status(): Get current progress (thread-safe, called from main thread)
|
|
19
|
+
- get_summary_dict(): Get final summary after completion
|
|
20
|
+
- abort(): Signal task to stop
|
|
21
|
+
|
|
22
|
+
Shared state across all task types:
|
|
23
|
+
- Device info: device_path, total_size
|
|
24
|
+
- Control: opts, do_abort, done
|
|
25
|
+
- Progress: total_written, start_mono, wr_hists
|
|
26
|
+
- Errors: exception
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# O_DIRECT requires aligned buffers and write sizes
|
|
30
|
+
BLOCK_SIZE = 4096 # Alignment requirement for O_DIRECT
|
|
31
|
+
WRITE_SIZE = 1 * 1024 * 1024 # 1MB (must be multiple of BLOCK_SIZE)
|
|
32
|
+
BUFFER_SIZE = WRITE_SIZE # Same size for O_DIRECT
|
|
33
|
+
|
|
34
|
+
# Marker constants (separate from O_DIRECT writes)
|
|
35
|
+
MARKER_SIZE = 16 * 1024 # 16KB for marker
|
|
36
|
+
STATE_OFFSET = 15 * 1024 # where json is written (for marker buffer)
|
|
37
|
+
|
|
38
|
+
# Aligned buffers allocated with mmap (initialized at module load)
|
|
39
|
+
buffer = None # Random data buffer (memoryview)
|
|
40
|
+
buffer_mem = None # Underlying mmap object
|
|
41
|
+
zero_buffer = None # Zero buffer (memoryview)
|
|
42
|
+
zero_buffer_mem = None # Underlying mmap object
|
|
43
|
+
|
|
44
|
+
def __init__(self, device_path, total_size, opts=None):
|
|
45
|
+
"""Initialize base task with common attributes
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
device_path: Path to device (e.g., '/dev/sda1')
|
|
49
|
+
total_size: Total size in bytes
|
|
50
|
+
opts: Options namespace (wipe_mode, verify_pct, etc.)
|
|
51
|
+
"""
|
|
52
|
+
self.device_path = device_path
|
|
53
|
+
self.total_size = total_size
|
|
54
|
+
self.opts = opts
|
|
55
|
+
|
|
56
|
+
# Control flags
|
|
57
|
+
self.do_abort = False
|
|
58
|
+
self.done = False
|
|
59
|
+
self.exception = None
|
|
60
|
+
|
|
61
|
+
# Progress tracking
|
|
62
|
+
self.total_written = 0 # Bytes processed (write or verify)
|
|
63
|
+
self.start_mono = time.monotonic()
|
|
64
|
+
self.wr_hists = [] # Progress history: list of SimpleNamespace(mono, written)
|
|
65
|
+
self.wr_hists.append(SimpleNamespace(mono=self.start_mono, written=0))
|
|
66
|
+
|
|
67
|
+
def run_task(self):
|
|
68
|
+
"""Execute the task (blocking, runs in thread)
|
|
69
|
+
|
|
70
|
+
Must be implemented by subclasses. Should:
|
|
71
|
+
- Perform the actual work (write or verify)
|
|
72
|
+
- Update self.total_written as it progresses
|
|
73
|
+
- Check self.do_abort periodically and stop if True
|
|
74
|
+
- Set self.exception if errors occur
|
|
75
|
+
- Set self.done = True when complete (or use finally block)
|
|
76
|
+
"""
|
|
77
|
+
raise NotImplementedError("Subclasses must implement run_task()")
|
|
78
|
+
|
|
79
|
+
def get_status(self):
|
|
80
|
+
"""Get current progress status (thread-safe, called from main thread)
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
tuple: (elapsed_str, pct_str, rate_str, eta_str)
|
|
84
|
+
- elapsed_str: e.g., "5m23s"
|
|
85
|
+
- pct_str: e.g., "45%" or "v23%" (for verify)
|
|
86
|
+
- rate_str: e.g., "450MB/s"
|
|
87
|
+
- eta_str: e.g., "2m15s"
|
|
88
|
+
"""
|
|
89
|
+
mono = time.monotonic()
|
|
90
|
+
elapsed_time = mono - self.start_mono
|
|
91
|
+
|
|
92
|
+
# Calculate percentage
|
|
93
|
+
pct = (self.total_written / self.total_size) * 100 if self.total_size > 0 else 0
|
|
94
|
+
pct = min(pct, 100)
|
|
95
|
+
pct_str = f'{int(round(pct))}%'
|
|
96
|
+
|
|
97
|
+
if self.do_abort:
|
|
98
|
+
pct_str = 'STOP'
|
|
99
|
+
|
|
100
|
+
# Track progress for rate calculation
|
|
101
|
+
self.wr_hists.append(SimpleNamespace(mono=mono, written=self.total_written))
|
|
102
|
+
floor = mono - 30 # 30 second window
|
|
103
|
+
while len(self.wr_hists) >= 3 and self.wr_hists[1].mono >= floor:
|
|
104
|
+
del self.wr_hists[0]
|
|
105
|
+
|
|
106
|
+
# Calculate rate from sliding window
|
|
107
|
+
delta_mono = mono - self.wr_hists[0].mono
|
|
108
|
+
rate = (self.total_written - self.wr_hists[0].written) / delta_mono if delta_mono > 1.0 else 0
|
|
109
|
+
rate_str = f'{Utils.human(int(round(rate, 0)))}/s'
|
|
110
|
+
|
|
111
|
+
# Calculate ETA
|
|
112
|
+
if rate > 0:
|
|
113
|
+
remaining = self.total_size - self.total_written
|
|
114
|
+
when = int(round(remaining / rate))
|
|
115
|
+
when_str = Utils.ago_str(when)
|
|
116
|
+
else:
|
|
117
|
+
when_str = '0'
|
|
118
|
+
|
|
119
|
+
return Utils.ago_str(int(round(elapsed_time))), pct_str, rate_str, when_str
|
|
120
|
+
|
|
121
|
+
def get_summary_dict(self):
|
|
122
|
+
"""Get final summary after task completion
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
dict: Summary with step name, elapsed, rate, bytes processed, etc.
|
|
126
|
+
"""
|
|
127
|
+
mono = time.monotonic()
|
|
128
|
+
elapsed = mono - self.start_mono
|
|
129
|
+
rate_bps = self.total_written / elapsed if elapsed > 0 else 0
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
"step": f"task {self.__class__.__name__}",
|
|
133
|
+
"elapsed": Utils.ago_str(int(elapsed)),
|
|
134
|
+
"rate": f"{Utils.human(int(rate_bps))}/s",
|
|
135
|
+
"bytes_processed": self.total_written,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
def get_display_name(self):
|
|
139
|
+
"""Get human-readable name for this task type (for progress display)
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
str: Task name like "Zero", "Rand", "Crypto", "Verify", etc.
|
|
143
|
+
"""
|
|
144
|
+
return "Task" # Default, should be overridden
|
|
145
|
+
|
|
146
|
+
def abort(self):
|
|
147
|
+
"""Signal task to stop (thread-safe)"""
|
|
148
|
+
self.do_abort = True
|
dwipe/WriteTask.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WriteTask - Abstract base class and implementations for write operations
|
|
3
|
+
|
|
4
|
+
Includes:
|
|
5
|
+
- WriteTask: Abstract base class with write loop, error handling, performance monitoring
|
|
6
|
+
- WriteZeroTask: Concrete class for writing zeros
|
|
7
|
+
- WriteRandTask: Concrete class for writing random data
|
|
8
|
+
"""
|
|
9
|
+
# pylint: disable=broad-exception-raised,broad-exception-caught
|
|
10
|
+
import os
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
import subprocess
|
|
14
|
+
import traceback
|
|
15
|
+
from types import SimpleNamespace
|
|
16
|
+
|
|
17
|
+
from .WipeTask import WipeTask
|
|
18
|
+
from .Utils import Utils
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WriteTask(WipeTask):
|
|
22
|
+
"""Abstract base class for write operations (WriteZeroTask, WriteRandTask)
|
|
23
|
+
|
|
24
|
+
Implements the main write loop with:
|
|
25
|
+
- O_DIRECT unbuffered I/O for maximum performance
|
|
26
|
+
- Error handling with safe_write() and reopen on error
|
|
27
|
+
- Performance monitoring (stall/slowdown detection)
|
|
28
|
+
- Periodic marker updates for crash recovery
|
|
29
|
+
- Multi-pass support
|
|
30
|
+
|
|
31
|
+
Subclasses must implement:
|
|
32
|
+
- get_buffer(chunk_size): Return buffer slice (zeros or random)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, device_path, total_size, opts=None, resume_from=0, pass_number=0):
|
|
36
|
+
"""Initialize write task
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
device_path: Path to device (e.g., '/dev/sda1')
|
|
40
|
+
total_size: Total size in bytes (single pass)
|
|
41
|
+
opts: Options namespace
|
|
42
|
+
resume_from: Byte offset to resume from (0 for fresh start)
|
|
43
|
+
pass_number: Current pass number (0-indexed, for multi-pass)
|
|
44
|
+
"""
|
|
45
|
+
super().__init__(device_path, total_size, opts)
|
|
46
|
+
|
|
47
|
+
# Resume support
|
|
48
|
+
self.resume_from = resume_from
|
|
49
|
+
self.total_written = resume_from # Start from resume offset
|
|
50
|
+
self.current_pass = pass_number
|
|
51
|
+
|
|
52
|
+
# Marker updates for crash recovery
|
|
53
|
+
self.last_marker_update_mono = time.monotonic() - 25 # Last marker write
|
|
54
|
+
self.marker_update_interval = 30 # Update every 30 seconds
|
|
55
|
+
|
|
56
|
+
# Performance monitoring
|
|
57
|
+
self.slowdown_stop = getattr(opts, 'slowdown_stop', 16)
|
|
58
|
+
self.stall_timeout = getattr(opts, 'stall_timeout', 60)
|
|
59
|
+
self.max_slowdown_ratio = 0
|
|
60
|
+
self.max_stall_secs = 0
|
|
61
|
+
self.baseline_speed = None # Bytes per second baseline
|
|
62
|
+
self.baseline_end_mono = None
|
|
63
|
+
self.last_progress_mono = time.monotonic()
|
|
64
|
+
self.last_progress_written = resume_from
|
|
65
|
+
self.last_slowdown_check = 0
|
|
66
|
+
|
|
67
|
+
# Error handling
|
|
68
|
+
self.max_consecutive_errors = 3
|
|
69
|
+
self.max_total_errors = 100
|
|
70
|
+
self.reopen_on_error = True
|
|
71
|
+
self.reopen_count = 0
|
|
72
|
+
self.total_errors = 0
|
|
73
|
+
|
|
74
|
+
# Initialize write history
|
|
75
|
+
self.wr_hists = [SimpleNamespace(mono=self.start_mono, written=resume_from)]
|
|
76
|
+
|
|
77
|
+
def get_buffer(self, chunk_size):
|
|
78
|
+
"""Get buffer slice for writing (abstract method)
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
chunk_size: Number of bytes to return
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
memoryview: Buffer slice of requested size
|
|
85
|
+
|
|
86
|
+
Must be implemented by subclasses:
|
|
87
|
+
- WriteZeroTask returns WipeTask.zero_buffer[:chunk_size]
|
|
88
|
+
- WriteRandTask returns WipeTask.buffer[:chunk_size]
|
|
89
|
+
"""
|
|
90
|
+
raise NotImplementedError("Subclasses must implement get_buffer()")
|
|
91
|
+
|
|
92
|
+
def run_task(self):
|
|
93
|
+
"""Execute write operation (blocking, runs in thread)"""
|
|
94
|
+
try:
|
|
95
|
+
# Set low I/O priority
|
|
96
|
+
self._setup_ionice()
|
|
97
|
+
|
|
98
|
+
# Open device with O_DIRECT for unbuffered I/O
|
|
99
|
+
fd = os.open(self.device_path, os.O_WRONLY | os.O_DIRECT)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
# Start from resume offset if resuming
|
|
103
|
+
offset_in_pass = self.resume_from
|
|
104
|
+
|
|
105
|
+
# SKIP MARKER AREA - don't overwrite it!
|
|
106
|
+
if offset_in_pass < WipeTask.MARKER_SIZE:
|
|
107
|
+
self.total_written += WipeTask.MARKER_SIZE - offset_in_pass
|
|
108
|
+
offset_in_pass = WipeTask.MARKER_SIZE
|
|
109
|
+
|
|
110
|
+
# Seek to current position (O_DIRECT requires block-aligned seeks)
|
|
111
|
+
os.lseek(fd, offset_in_pass, os.SEEK_SET)
|
|
112
|
+
|
|
113
|
+
# Write until end of pass
|
|
114
|
+
bytes_to_write = self.total_size - offset_in_pass
|
|
115
|
+
bytes_written_this_run = 0
|
|
116
|
+
|
|
117
|
+
while bytes_written_this_run < bytes_to_write and not self.do_abort:
|
|
118
|
+
current_mono = time.monotonic()
|
|
119
|
+
|
|
120
|
+
# Update baseline if needed (first 60 seconds)
|
|
121
|
+
self._update_baseline_if_needed(current_mono)
|
|
122
|
+
|
|
123
|
+
# Check for stall (frequently)
|
|
124
|
+
if self._check_for_stall(current_mono):
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
# Check for slowdown (every 10 seconds)
|
|
128
|
+
if self.baseline_speed is not None:
|
|
129
|
+
time_since_last_check = current_mono - self.last_slowdown_check
|
|
130
|
+
if time_since_last_check >= 10:
|
|
131
|
+
if self._check_for_slowdown(current_mono):
|
|
132
|
+
break
|
|
133
|
+
self.last_slowdown_check = current_mono
|
|
134
|
+
|
|
135
|
+
# Update progress tracking
|
|
136
|
+
if self.total_written > self.last_progress_written:
|
|
137
|
+
self.last_progress_mono = current_mono
|
|
138
|
+
self.last_progress_written = self.total_written
|
|
139
|
+
|
|
140
|
+
# Calculate chunk size (must be block-aligned for O_DIRECT)
|
|
141
|
+
remaining = bytes_to_write - bytes_written_this_run
|
|
142
|
+
chunk_size = min(WipeTask.WRITE_SIZE, remaining)
|
|
143
|
+
# Round down to block boundary
|
|
144
|
+
chunk_size = (chunk_size // WipeTask.BLOCK_SIZE) * WipeTask.BLOCK_SIZE
|
|
145
|
+
if chunk_size == 0:
|
|
146
|
+
break
|
|
147
|
+
|
|
148
|
+
# Get buffer from subclass (polymorphic)
|
|
149
|
+
chunk = self.get_buffer(chunk_size)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
# Write with O_DIRECT (bypasses page cache)
|
|
153
|
+
bytes_written, fd = self.safe_write(fd, chunk)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
# Save exception for debugging
|
|
156
|
+
self.exception = str(e)
|
|
157
|
+
self.do_abort = True
|
|
158
|
+
bytes_written = 0
|
|
159
|
+
|
|
160
|
+
self.total_written += bytes_written
|
|
161
|
+
bytes_written_this_run += bytes_written
|
|
162
|
+
|
|
163
|
+
# Periodically update marker for crash recovery (every 30s)
|
|
164
|
+
if self.total_written > WipeTask.MARKER_SIZE:
|
|
165
|
+
self.maybe_update_marker()
|
|
166
|
+
|
|
167
|
+
# Check for errors or incomplete writes
|
|
168
|
+
if bytes_written < chunk_size:
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
finally:
|
|
172
|
+
# Close device file descriptor
|
|
173
|
+
if fd is not None:
|
|
174
|
+
os.close(fd)
|
|
175
|
+
|
|
176
|
+
self.done = True
|
|
177
|
+
except Exception:
|
|
178
|
+
self.exception = traceback.format_exc()
|
|
179
|
+
self.done = True
|
|
180
|
+
|
|
181
|
+
def safe_write(self, fd, chunk):
|
|
182
|
+
"""Safe write with error recovery and reopen logic
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
fd: File descriptor
|
|
186
|
+
chunk: Data to write
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
tuple: (bytes_written, fd) - fd might be new if reopened
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
Exception: If should abort (too many consecutive/total errors)
|
|
193
|
+
"""
|
|
194
|
+
consecutive_errors = 0
|
|
195
|
+
while True: # Keep trying until success, skip, or abort
|
|
196
|
+
try:
|
|
197
|
+
bytes_written = os.write(fd, chunk)
|
|
198
|
+
self.reopen_count = 0
|
|
199
|
+
return bytes_written, fd # success
|
|
200
|
+
|
|
201
|
+
except Exception as e:
|
|
202
|
+
consecutive_errors += 1
|
|
203
|
+
self.total_errors += 1
|
|
204
|
+
|
|
205
|
+
# Check if we should abort
|
|
206
|
+
if consecutive_errors >= self.max_consecutive_errors:
|
|
207
|
+
raise Exception(f"{consecutive_errors} consecutive write errors") from e
|
|
208
|
+
|
|
209
|
+
if self.total_errors >= self.max_total_errors:
|
|
210
|
+
raise Exception(f"{self.total_errors} total write errors") from e
|
|
211
|
+
|
|
212
|
+
# Not fatal yet - try reopening if enabled
|
|
213
|
+
if self.reopen_on_error:
|
|
214
|
+
try:
|
|
215
|
+
current_pos = self.total_written
|
|
216
|
+
# Open new fd first
|
|
217
|
+
new_fd = os.open(self.device_path, os.O_WRONLY | os.O_DIRECT)
|
|
218
|
+
try:
|
|
219
|
+
# Seek to correct position on new fd
|
|
220
|
+
os.lseek(new_fd, current_pos, os.SEEK_SET)
|
|
221
|
+
# Only close old fd after new one is ready
|
|
222
|
+
old_fd = fd
|
|
223
|
+
fd = new_fd
|
|
224
|
+
try:
|
|
225
|
+
os.close(old_fd)
|
|
226
|
+
except Exception:
|
|
227
|
+
pass # Old fd close failed, but new fd is good
|
|
228
|
+
self.reopen_count += 1
|
|
229
|
+
except Exception:
|
|
230
|
+
# New fd setup failed, close it and keep using old fd
|
|
231
|
+
os.close(new_fd)
|
|
232
|
+
raise
|
|
233
|
+
except Exception:
|
|
234
|
+
# Reopen failed - count as another error and retry with old fd
|
|
235
|
+
self.total_errors += 1
|
|
236
|
+
|
|
237
|
+
# Retry the write (continue loop)
|
|
238
|
+
|
|
239
|
+
def maybe_update_marker(self):
|
|
240
|
+
"""Periodically update marker to enable crash recovery
|
|
241
|
+
|
|
242
|
+
Updates marker every marker_update_interval seconds (default 30s).
|
|
243
|
+
This allows resume to work even after crashes, power loss, or kill -9.
|
|
244
|
+
"""
|
|
245
|
+
now_mono = time.monotonic()
|
|
246
|
+
if now_mono - self.last_marker_update_mono < self.marker_update_interval:
|
|
247
|
+
return # Not time yet
|
|
248
|
+
|
|
249
|
+
# Marker writes use separate file handle (buffered I/O, not O_DIRECT)
|
|
250
|
+
# because marker buffer is not aligned
|
|
251
|
+
try:
|
|
252
|
+
# Determine if this is a random or zero write
|
|
253
|
+
is_random = isinstance(self, WriteRandTask)
|
|
254
|
+
with open(self.device_path, 'r+b') as marker_file:
|
|
255
|
+
marker_file.seek(0)
|
|
256
|
+
marker_file.write(self._prep_marker_buffer(is_random))
|
|
257
|
+
self.last_marker_update_mono = now_mono
|
|
258
|
+
except Exception:
|
|
259
|
+
# If marker update fails, just continue - we'll try again in 30s
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
def _prep_marker_buffer(self, is_random):
|
|
263
|
+
"""Prepare marker buffer for this write task
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
is_random: bool, whether random data is being written
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
bytearray: 16KB marker buffer with JSON status
|
|
270
|
+
"""
|
|
271
|
+
data = {
|
|
272
|
+
"unixtime": int(time.time()),
|
|
273
|
+
"scrubbed_bytes": self.total_written,
|
|
274
|
+
"size_bytes": self.total_size,
|
|
275
|
+
"passes": 1, # Single pass per WriteTask
|
|
276
|
+
"mode": 'Rand' if is_random else 'Zero'
|
|
277
|
+
}
|
|
278
|
+
json_data = json.dumps(data).encode('utf-8')
|
|
279
|
+
buffer = bytearray(WipeTask.MARKER_SIZE) # Only 16KB, not 1MB
|
|
280
|
+
buffer[:WipeTask.STATE_OFFSET] = b'\x00' * WipeTask.STATE_OFFSET
|
|
281
|
+
buffer[WipeTask.STATE_OFFSET:WipeTask.STATE_OFFSET + len(json_data)] = json_data
|
|
282
|
+
remaining_size = WipeTask.MARKER_SIZE - (WipeTask.STATE_OFFSET + len(json_data))
|
|
283
|
+
buffer[WipeTask.STATE_OFFSET + len(json_data):] = b'\x00' * remaining_size
|
|
284
|
+
return buffer
|
|
285
|
+
|
|
286
|
+
def _check_for_stall(self, current_monotonic):
|
|
287
|
+
"""Check for stall (no progress) - called frequently"""
|
|
288
|
+
if self.stall_timeout <= 0:
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
time_since_progress = current_monotonic - self.last_progress_mono
|
|
292
|
+
self.max_stall_secs = max(time_since_progress, self.max_stall_secs)
|
|
293
|
+
if time_since_progress >= self.stall_timeout:
|
|
294
|
+
self.do_abort = True
|
|
295
|
+
self.exception = f"Stall detected: No progress for {time_since_progress:.1f} seconds"
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
def _check_for_slowdown(self, current_monotonic):
|
|
301
|
+
"""Check for slowdown - called every 10 seconds"""
|
|
302
|
+
if self.slowdown_stop <= 0 or self.baseline_speed is None or self.baseline_speed <= 0:
|
|
303
|
+
return False
|
|
304
|
+
|
|
305
|
+
# Calculate current speed over last 30 seconds
|
|
306
|
+
floor = current_monotonic - 30
|
|
307
|
+
recent_history = [h for h in self.wr_hists if h.mono >= floor]
|
|
308
|
+
|
|
309
|
+
if len(recent_history) >= 2:
|
|
310
|
+
recent_start = recent_history[0]
|
|
311
|
+
recent_written = self.total_written - recent_start.written
|
|
312
|
+
recent_elapsed = current_monotonic - recent_start.mono
|
|
313
|
+
|
|
314
|
+
if recent_elapsed > 1.0:
|
|
315
|
+
current_speed = recent_written / recent_elapsed
|
|
316
|
+
self.baseline_speed = max(self.baseline_speed, current_speed)
|
|
317
|
+
slowdown_ratio = self.baseline_speed / max(current_speed, 1)
|
|
318
|
+
slowdown_ratio = int(round(slowdown_ratio, 0))
|
|
319
|
+
self.max_slowdown_ratio = max(self.max_slowdown_ratio, slowdown_ratio)
|
|
320
|
+
|
|
321
|
+
if slowdown_ratio > self.slowdown_stop:
|
|
322
|
+
self.do_abort = True
|
|
323
|
+
self.exception = (f"Slowdown abort: ({Utils.human(current_speed)}B/s)"
|
|
324
|
+
f" is 1/{slowdown_ratio} baseline")
|
|
325
|
+
return True
|
|
326
|
+
|
|
327
|
+
return False
|
|
328
|
+
|
|
329
|
+
def _update_baseline_if_needed(self, current_monotonic):
|
|
330
|
+
"""Update baseline speed measurement if still in first 60 seconds"""
|
|
331
|
+
if self.baseline_speed is not None:
|
|
332
|
+
return # Baseline already established
|
|
333
|
+
|
|
334
|
+
if (current_monotonic - self.start_mono) >= 60:
|
|
335
|
+
total_written_60s = self.total_written - self.resume_from
|
|
336
|
+
elapsed_60s = current_monotonic - self.start_mono
|
|
337
|
+
if elapsed_60s > 0:
|
|
338
|
+
self.baseline_speed = total_written_60s / elapsed_60s
|
|
339
|
+
self.baseline_end_mono = current_monotonic
|
|
340
|
+
self.last_slowdown_check = current_monotonic # Start slowdown checking
|
|
341
|
+
|
|
342
|
+
def _setup_ionice(self):
|
|
343
|
+
"""Setup I/O priority to best-effort class, lowest priority"""
|
|
344
|
+
try:
|
|
345
|
+
# Class 2 = best-effort, priority 7 = lowest (0 is highest, 7 is lowest)
|
|
346
|
+
subprocess.run(["ionice", "-c", "2", "-n", "7", "-p", str(os.getpid())],
|
|
347
|
+
capture_output=True, check=False)
|
|
348
|
+
except Exception:
|
|
349
|
+
pass
|
|
350
|
+
|
|
351
|
+
def get_summary_dict(self):
|
|
352
|
+
"""Get final summary for this write task
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
dict: Summary with step name, elapsed, rate, bytes written, errors, etc.
|
|
356
|
+
"""
|
|
357
|
+
mono = time.monotonic()
|
|
358
|
+
elapsed = mono - self.start_mono
|
|
359
|
+
rate_bps = self.total_written / elapsed if elapsed > 0 else 0
|
|
360
|
+
|
|
361
|
+
# Determine mode from class name
|
|
362
|
+
mode = "Rand" if isinstance(self, WriteRandTask) else "Zero"
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
"step": f"wipe {mode} {self.device_path}",
|
|
366
|
+
"elapsed": Utils.ago_str(int(elapsed)),
|
|
367
|
+
"rate": f"{Utils.human(int(rate_bps))}/s",
|
|
368
|
+
"bytes_written": self.total_written,
|
|
369
|
+
"bytes_total": self.total_size,
|
|
370
|
+
"passes_total": 1, # Single pass per WriteTask
|
|
371
|
+
"passes_completed": 1 if self.done and not self.exception else 0,
|
|
372
|
+
"current_pass": self.current_pass,
|
|
373
|
+
"peak_write_rate": f"{Utils.human(int(self.baseline_speed))}/s" if self.baseline_speed else None,
|
|
374
|
+
"worst_stall": Utils.ago_str(int(self.max_stall_secs)),
|
|
375
|
+
"worst_slowdown_ratio": round(self.max_slowdown_ratio, 1),
|
|
376
|
+
"errors": self.total_errors,
|
|
377
|
+
"reopen_count": self.reopen_count,
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class WriteZeroTask(WriteTask):
|
|
382
|
+
"""Write zeros to disk"""
|
|
383
|
+
|
|
384
|
+
def get_buffer(self, chunk_size):
|
|
385
|
+
"""Return zero buffer slice"""
|
|
386
|
+
return WipeTask.zero_buffer[:chunk_size]
|
|
387
|
+
|
|
388
|
+
def get_display_name(self):
|
|
389
|
+
"""Get display name for zeros write"""
|
|
390
|
+
return "Zero"
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class WriteRandTask(WriteTask):
|
|
394
|
+
"""Write random data to disk"""
|
|
395
|
+
|
|
396
|
+
def get_buffer(self, chunk_size):
|
|
397
|
+
"""Return random buffer slice"""
|
|
398
|
+
return WipeTask.buffer[:chunk_size]
|
|
399
|
+
|
|
400
|
+
def get_display_name(self):
|
|
401
|
+
"""Get display name for random write"""
|
|
402
|
+
return "Rand"
|
dwipe/main.py
CHANGED
|
@@ -15,16 +15,17 @@ import traceback
|
|
|
15
15
|
from .DiskWipe import DiskWipe
|
|
16
16
|
from .DeviceInfo import DeviceInfo
|
|
17
17
|
from .Utils import Utils
|
|
18
|
+
from .Prereqs import Prereqs
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
def main():
|
|
21
22
|
"""Main entry point"""
|
|
22
23
|
import argparse
|
|
23
24
|
parser = argparse.ArgumentParser()
|
|
24
|
-
parser.add_argument('
|
|
25
|
-
help='
|
|
26
|
-
parser.add_argument('-
|
|
27
|
-
help='
|
|
25
|
+
parser.add_argument('--dump-lsblk', action='store_true',
|
|
26
|
+
help='dump parsed lsblk and exit for debugging')
|
|
27
|
+
parser.add_argument('-F', '--firmware-wipes', action='store_true',
|
|
28
|
+
help='enable experimental (alpha) firmware wipes')
|
|
28
29
|
opts = parser.parse_args()
|
|
29
30
|
|
|
30
31
|
dwipe = None # Initialize to None so exception handler can reference it
|
|
@@ -33,11 +34,15 @@ def main():
|
|
|
33
34
|
# Re-run the script with sudo needed and opted
|
|
34
35
|
Utils.rerun_module_as_root('dwipe.main')
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
prereqs = Prereqs(verbose=True)
|
|
38
|
+
# lsblk is critical for everything; hdparm and nvme only needed for firmware wipes
|
|
39
|
+
if opts.firmware_wipes:
|
|
40
|
+
prereqs.check_all(['lsblk', 'hdparm', 'nvme'])
|
|
41
|
+
else:
|
|
42
|
+
prereqs.check_all(['lsblk'])
|
|
43
|
+
prereqs.report_and_exit_if_failed()
|
|
44
|
+
|
|
45
|
+
dwipe = DiskWipe(opts=opts) # opts=opts)
|
|
41
46
|
|
|
42
47
|
dwipe.main_loop()
|
|
43
48
|
except Exception as exce:
|