1#
2# Copyright (C) 2012 Robert Yang
3#
4# SPDX-License-Identifier: GPL-2.0-only
5#
6
7import os, logging, re
8import bb
9logger = logging.getLogger("BitBake.Monitor")
10
11def printErr(info):
12    logger.error("%s\n       Disk space monitor will NOT be enabled" % info)
13
14def convertGMK(unit):
15
16    """ Convert the space unit G, M, K, the unit is case-insensitive """
17
18    unitG = re.match(r'([1-9][0-9]*)[gG]\s?$', unit)
19    if unitG:
20        return int(unitG.group(1)) * (1024 ** 3)
21    unitM = re.match(r'([1-9][0-9]*)[mM]\s?$', unit)
22    if unitM:
23        return int(unitM.group(1)) * (1024 ** 2)
24    unitK = re.match(r'([1-9][0-9]*)[kK]\s?$', unit)
25    if unitK:
26        return int(unitK.group(1)) * 1024
27    unitN = re.match(r'([1-9][0-9]*)\s?$', unit)
28    if unitN:
29        return int(unitN.group(1))
30    else:
31        return None
32
33def getMountedDev(path):
34
35    """ Get the device mounted at the path, uses /proc/mounts """
36
37    # Get the mount point of the filesystem containing path
38    # st_dev is the ID of device containing file
39    parentDev = os.stat(path).st_dev
40    currentDev = parentDev
41    # When the current directory's device is different from the
42    # parent's, then the current directory is a mount point
43    while parentDev == currentDev:
44        mountPoint = path
45        # Use dirname to get the parent's directory
46        path = os.path.dirname(path)
47        # Reach the "/"
48        if path == mountPoint:
49            break
50        parentDev= os.stat(path).st_dev
51
52    try:
53        with open("/proc/mounts", "r") as ifp:
54            for line in ifp:
55                procLines = line.rstrip('\n').split()
56                if procLines[1] == mountPoint:
57                    return procLines[0]
58    except EnvironmentError:
59        pass
60    return None
61
62def getDiskData(BBDirs):
63
64    """Prepare disk data for disk space monitor"""
65
66    # Save the device IDs, need the ID to be unique (the dictionary's key is
67    # unique), so that when more than one directory is located on the same
68    # device, we just monitor it once
69    devDict = {}
70    for pathSpaceInode in BBDirs.split():
71        # The input format is: "dir,space,inode", dir is a must, space
72        # and inode are optional
73        pathSpaceInodeRe = re.match(r'([^,]*),([^,]*),([^,]*),?(.*)', pathSpaceInode)
74        if not pathSpaceInodeRe:
75            printErr("Invalid value in BB_DISKMON_DIRS: %s" % pathSpaceInode)
76            return None
77
78        action = pathSpaceInodeRe.group(1)
79        if action == "ABORT":
80            # Emit a deprecation warning
81            logger.warnonce("The BB_DISKMON_DIRS \"ABORT\" action has been renamed to \"HALT\", update configuration")
82            action = "HALT"
83
84        if action not in ("HALT", "STOPTASKS", "WARN"):
85            printErr("Unknown disk space monitor action: %s" % action)
86            return None
87
88        path = os.path.realpath(pathSpaceInodeRe.group(2))
89        if not path:
90            printErr("Invalid path value in BB_DISKMON_DIRS: %s" % pathSpaceInode)
91            return None
92
93        # The disk space or inode is optional, but it should have a correct
94        # value once it is specified
95        minSpace = pathSpaceInodeRe.group(3)
96        if minSpace:
97            minSpace = convertGMK(minSpace)
98            if not minSpace:
99                printErr("Invalid disk space value in BB_DISKMON_DIRS: %s" % pathSpaceInodeRe.group(3))
100                return None
101        else:
102            # None means that it is not specified
103            minSpace = None
104
105        minInode = pathSpaceInodeRe.group(4)
106        if minInode:
107            minInode = convertGMK(minInode)
108            if not minInode:
109                printErr("Invalid inode value in BB_DISKMON_DIRS: %s" % pathSpaceInodeRe.group(4))
110                return None
111        else:
112            # None means that it is not specified
113            minInode = None
114
115        if minSpace is None and minInode is None:
116            printErr("No disk space or inode value in found BB_DISKMON_DIRS: %s" % pathSpaceInode)
117            return None
118        # mkdir for the directory since it may not exist, for example the
119        # DL_DIR may not exist at the very beginning
120        if not os.path.exists(path):
121            bb.utils.mkdirhier(path)
122        dev = getMountedDev(path)
123        # Use path/action as the key
124        devDict[(path, action)] = [dev, minSpace, minInode]
125
126    return devDict
127
128def getInterval(configuration):
129
130    """ Get the disk space interval """
131
132    # The default value is 50M and 5K.
133    spaceDefault = 50 * 1024 * 1024
134    inodeDefault = 5 * 1024
135
136    interval = configuration.getVar("BB_DISKMON_WARNINTERVAL")
137    if not interval:
138        return spaceDefault, inodeDefault
139    else:
140        # The disk space or inode interval is optional, but it should
141        # have a correct value once it is specified
142        intervalRe = re.match(r'([^,]*),?\s*(.*)', interval)
143        if intervalRe:
144            intervalSpace = intervalRe.group(1)
145            if intervalSpace:
146                intervalSpace = convertGMK(intervalSpace)
147                if not intervalSpace:
148                    printErr("Invalid disk space interval value in BB_DISKMON_WARNINTERVAL: %s" % intervalRe.group(1))
149                    return None, None
150            else:
151                intervalSpace = spaceDefault
152            intervalInode = intervalRe.group(2)
153            if intervalInode:
154                intervalInode = convertGMK(intervalInode)
155                if not intervalInode:
156                    printErr("Invalid disk inode interval value in BB_DISKMON_WARNINTERVAL: %s" % intervalRe.group(2))
157                    return None, None
158            else:
159                intervalInode = inodeDefault
160            return intervalSpace, intervalInode
161        else:
162            printErr("Invalid interval value in BB_DISKMON_WARNINTERVAL: %s" % interval)
163            return None, None
164
165class diskMonitor:
166
167    """Prepare the disk space monitor data"""
168
169    def __init__(self, configuration):
170
171        self.enableMonitor = False
172        self.configuration = configuration
173
174        BBDirs = configuration.getVar("BB_DISKMON_DIRS") or None
175        if BBDirs:
176            self.devDict = getDiskData(BBDirs)
177            if self.devDict:
178                self.spaceInterval, self.inodeInterval = getInterval(configuration)
179                if self.spaceInterval and self.inodeInterval:
180                    self.enableMonitor = True
181                    # These are for saving the previous disk free space and inode, we
182                    # use them to avoid printing too many warning messages
183                    self.preFreeS = {}
184                    self.preFreeI = {}
185                    # This is for STOPTASKS and HALT, to avoid printing the message
186                    # repeatedly while waiting for the tasks to finish
187                    self.checked = {}
188                    for k in self.devDict:
189                        self.preFreeS[k] = 0
190                        self.preFreeI[k] = 0
191                        self.checked[k] = False
192                    if self.spaceInterval is None and self.inodeInterval is None:
193                        self.enableMonitor = False
194
195    def check(self, rq):
196
197        """ Take action for the monitor """
198
199        if self.enableMonitor:
200            diskUsage = {}
201            for k, attributes in self.devDict.items():
202                path, action = k
203                dev, minSpace, minInode = attributes
204
205                st = os.statvfs(path)
206
207                # The available free space, integer number
208                freeSpace = st.f_bavail * st.f_frsize
209
210                # Send all relevant information in the event.
211                freeSpaceRoot = st.f_bfree * st.f_frsize
212                totalSpace = st.f_blocks * st.f_frsize
213                diskUsage[dev] = bb.event.DiskUsageSample(freeSpace, freeSpaceRoot, totalSpace)
214
215                if minSpace and freeSpace < minSpace:
216                    # Always show warning, the self.checked would always be False if the action is WARN
217                    if self.preFreeS[k] == 0 or self.preFreeS[k] - freeSpace > self.spaceInterval and not self.checked[k]:
218                        logger.warning("The free space of %s (%s) is running low (%.3fGB left)" % \
219                                (path, dev, freeSpace / 1024 / 1024 / 1024.0))
220                        self.preFreeS[k] = freeSpace
221
222                    if action == "STOPTASKS" and not self.checked[k]:
223                        logger.error("No new tasks can be executed since the disk space monitor action is \"STOPTASKS\"!")
224                        self.checked[k] = True
225                        rq.finish_runqueue(False)
226                        bb.event.fire(bb.event.DiskFull(dev, 'disk', freeSpace, path), self.configuration)
227                    elif action == "HALT" and not self.checked[k]:
228                        logger.error("Immediately halt since the disk space monitor action is \"HALT\"!")
229                        self.checked[k] = True
230                        rq.finish_runqueue(True)
231                        bb.event.fire(bb.event.DiskFull(dev, 'disk', freeSpace, path), self.configuration)
232
233                # The free inodes, integer number
234                freeInode = st.f_favail
235
236                if minInode and freeInode < minInode:
237                    # Some filesystems use dynamic inodes so can't run out.
238                    # This is reported by the inode count being 0 (btrfs) or the free
239                    # inode count being -1 (cephfs).
240                    if st.f_files == 0 or st.f_favail == -1:
241                        self.devDict[k][2] = None
242                        continue
243                    # Always show warning, the self.checked would always be False if the action is WARN
244                    if self.preFreeI[k] == 0 or self.preFreeI[k] - freeInode > self.inodeInterval and not self.checked[k]:
245                        logger.warning("The free inode of %s (%s) is running low (%.3fK left)" % \
246                                (path, dev, freeInode / 1024.0))
247                        self.preFreeI[k] = freeInode
248
249                    if action  == "STOPTASKS" and not self.checked[k]:
250                        logger.error("No new tasks can be executed since the disk space monitor action is \"STOPTASKS\"!")
251                        self.checked[k] = True
252                        rq.finish_runqueue(False)
253                        bb.event.fire(bb.event.DiskFull(dev, 'inode', freeInode, path), self.configuration)
254                    elif action  == "HALT" and not self.checked[k]:
255                        logger.error("Immediately halt since the disk space monitor action is \"HALT\"!")
256                        self.checked[k] = True
257                        rq.finish_runqueue(True)
258                        bb.event.fire(bb.event.DiskFull(dev, 'inode', freeInode, path), self.configuration)
259
260            bb.event.fire(bb.event.MonitorDiskEvent(diskUsage), self.configuration)
261        return
262