1 /*
2  * Copyright (C) 2020, 2021, 2022  Vladimir Panteleev <btdu@cy.md>
3  *
4  * This program is free software; you can redistribute it and/or
5  * modify it under the terms of the GNU General Public
6  * License v2 as published by the Free Software Foundation.
7  *
8  * This program is distributed in the hope that it will be useful,
9  * but WITHOUT ANY WARRANTY; without even the implied warranty of
10  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
11  * General Public License for more details.
12  *
13  * You should have received a copy of the GNU General Public
14  * License along with this program; if not, write to the
15  * Free Software Foundation, Inc., 59 Temple Place - Suite 330,
16  * Boston, MA 021110-1307, USA.
17  */
18 
19 /// btdu entry point
20 module btdu.main;
21 
22 import core.lifetime : move;
23 import core.runtime : Runtime;
24 import core.time;
25 
26 import std.conv : to;
27 import std.exception;
28 import std.parallelism : totalCPUs;
29 import std.path;
30 import std.random;
31 import std.socket;
32 import std.stdio;
33 import std.string;
34 
35 import ae.sys.data;
36 import ae.sys.datamm;
37 import ae.sys.file : getPathMountInfo;
38 import ae.sys.shutdown;
39 import ae.utils.funopt;
40 import ae.utils.json;
41 import ae.utils.main;
42 import ae.utils.time.parsedur;
43 
44 import btdu.browser;
45 import btdu.common;
46 import btdu.paths;
47 import btdu.sample;
48 import btdu.subproc;
49 import btdu.state;
50 
51 @(`Sampling disk usage profiler for btrfs.`)
52 void program(
53 	Parameter!(string, "Path to the root of the filesystem to analyze") path,
54 	Option!(uint, "Number of sampling subprocesses\n (default is number of logical CPUs for this system)", "N", 'j') procs = 0,
55 	Option!(Seed, "Random seed used to choose samples") seed = 0,
56 	Switch!hiddenOption subprocess = false,
57 	Switch!("Expert mode: collect and show additional metrics.\nUses more memory.") expert = false,
58 	Switch!hiddenOption man = false,
59 	Switch!("Run without launching the result browser UI.") headless = false,
60 	Option!(ulong, "Stop after collecting N samples.", "N", 'n') maxSamples = 0,
61 	Option!(string, "Stop after running for this duration.", "DURATION") maxTime = null,
62 	Option!(string, "Stop after achieving this resolution.", "SIZE") minResolution = null,
63 	Option!(string, "On exit, export the collected results to the given file.", "PATH", 'o', "export") exportPath = null,
64 	Switch!("Instead of analyzing a btrfs filesystem, read previously collected results saved with --export from PATH.", 'f', "import") doImport = false,
65 )
66 {
67 	if (man)
68 	{
69 		stdout.write(generateManPage!program(
70 			"btdu",
71 			".B btdu
72 is a sampling disk usage profiler for btrfs.
73 
74 For a detailed description, please see the full documentation:
75 
76 .I https://github.com/CyberShadow/btdu#readme",
77 			null,
78 			`.SH BUGS
79 Please report defects and enhancement requests to the GitHub issue tracker:
80 
81 .I https://github.com/CyberShadow/btdu/issues
82 
83 .SH AUTHORS
84 
85 \fBbtdu\fR is written by Vladimir Panteleev <btdu@c\fRy.m\fRd> and contributors:
86 
87 .I https://github.com/CyberShadow/btdu/graphs/contributors
88 `,
89 		));
90 		return;
91 	}
92 
93 	Data importData; // Keep memory-mapped file alive, as directory names may reference it
94 	if (doImport)
95 	{
96 		if (procs || seed || subprocess || expert || headless || maxSamples || maxTime || minResolution || exportPath)
97 			throw new Exception("Conflicting command-line options");
98 
99 		stderr.writeln("Loading results from file...");
100 		importData = mapFile(path, MmMode.read);
101 		auto json = cast(string)importData.contents;
102 
103 		debug importing = true;
104 		auto s = json.jsonParse!SerializedState();
105 
106 		expert = s.expert;
107 		fsPath = s.fsPath;
108 		totalSize = s.totalSize;
109 		move(*s.root, browserRoot);
110 
111 		browserRoot.resetParents();
112 		debug importing = false;
113 		imported = true;
114 	}
115 	else
116 	{
117 		rndGen = Random(seed);
118 		fsPath = path.buildNormalizedPath;
119 
120 		if (subprocess)
121 			return subprocessMain(path);
122 
123 		checkBtrfs(fsPath);
124 
125 		if (procs == 0)
126 			procs = totalCPUs;
127 
128 		subprocesses = new Subprocess[procs];
129 		foreach (ref subproc; subprocesses)
130 			subproc.start();
131 	}
132 
133 	Duration parsedMaxTime;
134 	if (maxTime)
135 		parsedMaxTime = parseDuration(maxTime);
136 
137 	real parsedMinResolution;
138 	if (minResolution)
139 		parsedMinResolution = parseSize(minResolution);
140 
141 	Socket stdinSocket;
142 	if (!headless)
143 	{
144 		stdinSocket = new Socket(cast(socket_t)stdin.fileno, AddressFamily.UNSPEC);
145 		stdinSocket.blocking = false;
146 	}
147 
148 	.expert = expert;
149 
150 	Browser browser;
151 	if (!headless)
152 	{
153 		browser.start();
154 		browser.update();
155 	}
156 
157 	auto startTime = MonoTime.currTime();
158 	enum refreshInterval = 500.msecs;
159 	auto nextRefresh = startTime;
160 
161 	auto readSet = new SocketSet;
162 	auto exceptSet = new SocketSet;
163 
164 	bool run = true;
165 	if (headless) // In non-headless mode, ncurses takes care of this
166 		addShutdownHandler((reason) {
167 			run = false;
168 		});
169 
170 	// Main event loop
171 	while (run)
172 	{
173 		readSet.reset();
174 		exceptSet.reset();
175 		if (stdinSocket)
176 		{
177 			readSet.add(stdinSocket);
178 			exceptSet.add(stdinSocket);
179 		}
180 		if (!paused)
181 			foreach (ref subproc; subprocesses)
182 				readSet.add(subproc.socket);
183 
184 		if (!headless && browser.needRefresh())
185 			Socket.select(readSet, null, exceptSet, refreshInterval);
186 		else
187 			Socket.select(readSet, null, exceptSet);
188 		auto now = MonoTime.currTime();
189 
190 		if (stdinSocket && browser.handleInput())
191 		{
192 			do {} while (browser.handleInput()); // Process all input
193 			if (browser.done)
194 				break;
195 			browser.update();
196 			nextRefresh = now + refreshInterval;
197 		}
198 		if (!paused)
199 			foreach (ref subproc; subprocesses)
200 				if (readSet.isSet(subproc.socket))
201 					subproc.handleInput();
202 		if (!headless && now > nextRefresh)
203 		{
204 			browser.update();
205 			nextRefresh = now + refreshInterval;
206 		}
207 
208 		if ((maxSamples && browserRoot.data[SampleType.represented].samples >= maxSamples) ||
209 			(maxTime && now > startTime + parsedMaxTime) ||
210 			(minResolution && (totalSize / browserRoot.data[SampleType.represented].samples) <= parsedMinResolution))
211 		{
212 			if (headless)
213 				break;
214 			else
215 			{
216 				if (!paused)
217 				{
218 					browser.togglePause();
219 					browser.update();
220 				}
221 				// Only pause once
222 				maxSamples = 0;
223 				maxTime = minResolution = null;
224 			}
225 		}
226 	}
227 
228 	if (headless)
229 	{
230 		auto totalSamples = browserRoot.data[SampleType.represented].samples;
231 		stderr.writefln(
232 			"Collected %s samples (achieving a resolution of ~%s) in %s.",
233 			totalSamples,
234 			totalSamples ? (totalSize / totalSamples).humanSize() : "-",
235 			MonoTime.currTime() - startTime,
236 		);
237 	}
238 
239 	if (exportPath)
240 	{
241 		stderr.writeln("Exporting results...");
242 
243 		SerializedState s;
244 		s.expert = expert;
245 		s.fsPath = fsPath;
246 		s.totalSize = totalSize;
247 		s.root = &browserRoot;
248 
249 		alias LockingBinaryWriter = typeof(File.lockingBinaryWriter());
250 		alias JsonFileSerializer = CustomJsonSerializer!(JsonWriter!LockingBinaryWriter);
251 
252 		{
253 			JsonFileSerializer j;
254 			auto file = exportPath == "-" ? stdout : File(exportPath, "wb");
255 			j.writer.output = file.lockingBinaryWriter;
256 			j.put(s);
257 		}
258 		stderr.writeln("Exported results to: ", exportPath);
259 	}
260 }
261 
262 /// Serialized
263 struct SerializedState
264 {
265 	bool expert;
266 	string fsPath;
267 	ulong totalSize;
268 	BrowserPath* root;
269 }
270 
271 void checkBtrfs(string fsPath)
272 {
273 	import core.sys.posix.fcntl : open, O_RDONLY;
274 	import std.string : toStringz;
275 	import btrfs : isBTRFS, isSubvolume, getSubvolumeID;
276 	import btrfs.c.kernel_shared.ctree : BTRFS_FS_TREE_OBJECTID;
277 
278 	int fd = open(fsPath.toStringz, O_RDONLY);
279 	errnoEnforce(fd >= 0, "open");
280 
281 	enforce(fd.isBTRFS,
282 		fsPath ~ " is not a btrfs filesystem");
283 
284 	enforce(fd.isSubvolume, {
285 		auto rootPath = getPathMountInfo(fsPath).file;
286 		if (!rootPath)
287 			rootPath = "/";
288 		return format(
289 			"%s is not the root of a btrfs subvolume - " ~
290 			"please specify the path to the subvolume root" ~
291 			"\n" ~
292 			"E.g.: %s",
293 			fsPath,
294 			[Runtime.args[0], rootPath].escapeShellCommand,
295 		);
296 	}());
297 
298 	enforce(fd.getSubvolumeID() == BTRFS_FS_TREE_OBJECTID, {
299 		auto device = getPathMountInfo(fsPath).spec;
300 		if (!device)
301 			device = "/dev/sda1"; // placeholder
302 		auto tmpName = "/tmp/" ~ device.baseName;
303 		return format(
304 			"%s is not the root btrfs subvolume - " ~
305 			"please specify the path to a mountpoint mounted with subvol=/ or subvolid=5" ~
306 			"\n" ~
307 			"E.g.: %s && %s && %s",
308 			fsPath,
309 			["mkdir", tmpName].escapeShellCommand,
310 			["mount", "-o", "subvol=/", device, tmpName].escapeShellCommand,
311 			[Runtime.args[0], tmpName].escapeShellCommand,
312 		);
313 	}());
314 }
315 
316 private string escapeShellCommand(string[] args)
317 {
318 	import std.process : escapeShellFileName;
319 	import std.algorithm.searching : all;
320 	import ae.utils.array : isOneOf;
321 
322 	foreach (ref arg; args)
323 		if (!arg.representation.all!(c => c.isOneOf("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_/.=:@%")))
324 			arg = arg.escapeShellFileName;
325 	return args.join(" ");
326 }
327 
328 void usageFun(string usage)
329 {
330 	stderr.writeln("btdu v" ~ btduVersion);
331 	stderr.writeln(usage);
332 }
333 
334 mixin main!(funopt!(program, FunOptConfig.init, usageFun));