fc9ad4575c0d746cad690550e36d59147e0b8b85
[mirror/dsa-puppet.git] / modules / porterbox / files / mail-big-homedirs
1 #!/usr/bin/python
2 ## vim:set et ts=2 sw=2 ai:
3 # Send email reminders to users having sizable homedirs.
4 ##
5 # Copyright (c) 2013 Philipp Kern <pkern@debian.org>
6 # Copyright (c) 2013, 2014 Peter Palfrader <peter@palfrader.org>
7 # Copyright (c) 2013 Luca Filipozzi <lfilipoz@debian.org>
8 #
9 # Permission is hereby granted, free of charge, to any person obtaining a copy
10 # of this software and associated documentation files (the "Software"), to deal
11 # in the Software without restriction, including without limitation the rights
12 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 # copies of the Software, and to permit persons to whom the Software is
14 # furnished to do so, subject to the following conditions:
15 #
16 # The above copyright notice and this permission notice shall be included in
17 # all copies or substantial portions of the Software.
18 #
19 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25 # THE SOFTWARE.
26
27 from collections import defaultdict
28 from dsa_mq.connection import Connection
29 from dsa_mq.config import Config
30 import email
31 import email.mime.text
32 import glob
33 import logging
34 from optparse import OptionParser
35 import os.path
36 import platform
37 import pwd
38 import subprocess
39 import struct
40 import time
41 import StringIO
42
43 # avoid base64 encoding for utf-8
44 email.charset.add_charset('utf-8', email.charset.SHORTEST, email.charset.QP)
45
46 parser = OptionParser()
47 parser.add_option("-D", "--dryrun",
48                   action="store_true", default=False,
49                   help="Dry run mode")
50
51 parser.add_option("-d", "--debug",
52                   action="store_true", default=False,
53                   help="Enable debug output")
54
55 (options, args) = parser.parse_args()
56 options.section = 'dsa-homedirs'
57 options.config = '/etc/dsa/pubsub.conf'
58 if os.access(options.config, os.R_OK):
59   mq_config = Config(options)
60   mq_conf  = {
61     'rabbit_userid': mq_config.username,
62     'rabbit_password': mq_config.password,
63     'rabbit_virtual_host': mq_config.vhost,
64     'rabbit_hosts': ['pubsub02.debian.org', 'pubsub01.debian.org'],
65     'use_ssl': False
66   }
67 else:
68   mq_config = None
69
70 if options.dryrun:
71   SENDMAIL_COMMAND = ['/bin/cat']
72   RM_COMMAND = ['/bin/echo', 'Would remove']
73 else:
74   SENDMAIL_COMMAND = ['/usr/sbin/sendmail', '-t', '-oi']
75   RM_COMMAND = ['/bin/rm', '-rf']
76
77 CRITERIA = [
78     { 'size': 10240,  'notifyafter':  5, 'deleteafter':  40 },
79     { 'size':  1024,  'notifyafter': 10, 'deleteafter':  50 },
80     { 'size':   100,  'notifyafter': 30, 'deleteafter':  90 },
81     { 'size':    20,  'notifyafter': 90, 'deleteafter': 150 },
82     { 'size':     5,                     'deleteafter': 700 }
83   ]
84 EXCLUDED_USERNAMES = ['lost+found', 'debian', 'buildd', 'd-i']
85 MAIL_FROM = 'debian-admin (via Cron) <bulk@admin.debian.org>'
86 MAIL_TO = '{username}@{hostname}.debian.org'
87 MAIL_CC = 'debian-admin (bulk sink) <bulk@admin.debian.org>'
88 MAIL_REPLYTO = 'debian-admin <dsa@debian.org>'
89 MAIL_SUBJECT = 'Please clean up ~{username} on {hostname}.debian.org'
90 MAIL_MESSAGE = u"""\
91 Hi {realname}!
92
93 Thanks for your porting effort on {hostname}!
94
95 Please note that, on most porterboxes, /home is quite small, so please
96 remove files that you do not need anymore.
97
98 For your information, you last logged into {hostname} {days_ago} days
99 ago, and your home directory there is {homedir_size} MB in size.
100
101 If you currently do not use {hostname}, please keep ~{username} under
102 10 MB, if possible.
103
104 Please assist us in freeing up space by deleting schroots, also.
105
106 Thanks,
107
108 Debian System Administration Team via Cron
109
110 PS: A reply is not required.
111 """
112
113 class Error(Exception):
114   pass
115
116 class SendmailError(Error):
117   pass
118
119 class LastlogTimes(dict):
120   LASTLOG_STRUCT_32 = '=L32s256s'
121   LASTLOG_STRUCT_64 = '=Q32s256s'
122
123   def __init__(self):
124     record_size_32 = struct.calcsize(self.LASTLOG_STRUCT_32)
125     record_size_64 = struct.calcsize(self.LASTLOG_STRUCT_64)
126     # some 64bit arches have 32bit-compatible lastlog structures, others don't,
127     # in apparently incoherent ways, so hardcode a list...
128     if platform.machine() in ('ia64', 'aarch64', 's390x'):
129         self.LASTLOG_STRUCT = self.LASTLOG_STRUCT_64
130         record_size = record_size_64
131     else:
132         self.LASTLOG_STRUCT = self.LASTLOG_STRUCT_32
133         record_size = record_size_32
134     with open('/var/log/lastlog', 'r') as fp:
135       uid = -1 # there is one record per uid in lastlog
136       for record in iter(lambda: fp.read(record_size), ''):
137         if len(record) != record_size:
138             raise RuntimeError('lastlog has unexpected size, read %d instead of %d'
139                                % (len(record), record_size))
140         uid += 1 # so keep incrementing uid for each record read
141         lastlog_time, _, _ = list(struct.unpack(self.LASTLOG_STRUCT, record))
142         if lastlog_time < 0:
143             raise RuntimeError('unexpected last login time %d for user %s'
144                                % (lastlog_time, pwd.getpwuid(uid).pw_name))
145         try:
146           self[pwd.getpwuid(uid).pw_name] = lastlog_time
147         except KeyError:
148           # this is a normal condition
149           continue
150
151 class HomedirSizes(dict):
152   def __init__(self):
153     for direntry in glob.glob('/home/*'):
154       username = os.path.basename(direntry)
155
156       if username in EXCLUDED_USERNAMES:
157         continue
158
159       try:
160         pwinfo = pwd.getpwnam(username)
161       except KeyError:
162         if os.path.isdir(direntry):
163           logging.warning('directory %s exists on %s but there is no %s user', direntry, platform.node(), username)
164         continue
165
166       if pwinfo.pw_dir != direntry:
167         logging.warning('home directory for %s is not %s, but that exists.  confused.', username, direntry)
168         continue
169
170       command = ['/usr/bin/du', '-ms', pwinfo.pw_dir]
171       p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
172       (stdout, stderr) = p.communicate()
173       if p.returncode != 0: # ignore errors from du
174         logging.info('%s failed:', ' '.join(command))
175         logging.info(stderr)
176         continue
177       try:
178         self[username] = int(stdout.split('\t')[0])
179       except ValueError:
180         logging.error('could not convert size output from %s: %s', ' '.join(command), stdout)
181         continue
182
183 class HomedirReminder(object):
184   def __init__(self):
185     self.lastlog_times = LastlogTimes()
186     self.homedir_sizes = HomedirSizes()
187
188   def notify(self, **kwargs):
189     msg = email.mime.text.MIMEText(MAIL_MESSAGE.format(**kwargs), _charset='UTF-8')
190     msg['From'] = MAIL_FROM.format(**kwargs)
191     msg['To'] = MAIL_TO.format(**kwargs)
192     if MAIL_CC != "":
193       msg['Cc'] = MAIL_CC.format(**kwargs)
194     if MAIL_REPLYTO != "":
195       msg['Reply-To'] = MAIL_REPLYTO.format(**kwargs)
196     msg['Subject'] = MAIL_SUBJECT.format(**kwargs)
197     msg['Precedence'] = "bulk"
198     msg['Auto-Submitted'] = "auto-generated by mail-big-homedirs"
199     p = subprocess.Popen(SENDMAIL_COMMAND, stdin=subprocess.PIPE)
200     p.communicate(msg.as_string())
201     logging.debug(msg.as_string())
202     if p.returncode != 0:
203       raise SendmailError
204
205   def remove(self, **kwargs):
206     try:
207       pwinfo = pwd.getpwnam(kwargs.get('username'))
208     except KeyError:
209       return
210
211     command = RM_COMMAND + [pwinfo.pw_dir]
212     p = subprocess.check_call(command)
213
214   def run(self):
215     current_time = time.time()
216     conn = None
217     try:
218       data = {}
219       for user in set(self.homedir_sizes.keys()) | \
220                   set(self.lastlog_times.keys()):
221         data[user] = {
222           'homedir': self.homedir_sizes.get(user, 0),
223           'lastlog': self.lastlog_times.get(user, 0),
224         }
225
226       if mq_config is not None:
227         msg = {
228           'timestamp': current_time,
229           'data': data,
230           'host': platform.node(),
231         }
232         conn = Connection(conf=mq_conf)
233         conn.topic_send(mq_config.topic,
234                         msg,
235                         exchange_name=mq_config.exchange,
236                         timeout=5)
237     except Exception, e:
238       logging.error("Error sending: %s" % e)
239     finally:
240       if conn:
241         conn.close()
242
243     for username, homedir_size in self.homedir_sizes.iteritems():
244       try:
245         realname = pwd.getpwnam(username).pw_gecos.decode('utf-8').split(',', 1)[0]
246       except:
247         realname = username
248       lastlog_time = self.lastlog_times.get(username, 0)
249       days_ago = int( (current_time - lastlog_time) / 3600 / 24 )
250       kwargs = {
251           'hostname': platform.node(),
252           'username': username,
253           'realname': realname,
254           'homedir_size': homedir_size,
255           'days_ago': days_ago
256         }
257
258       notify = False
259       remove = False
260       for x in CRITERIA:
261         if homedir_size > x['size'] and 'notifyafter' in x and days_ago >= x['notifyafter']:
262           notify = True
263         if homedir_size > x['size'] and 'deleteafter' in x and days_ago >= x['deleteafter']:
264           remove = True
265
266       if remove:
267         self.remove(**kwargs)
268       elif notify:
269         self.notify(**kwargs)
270
271 if __name__ == '__main__':
272   lvl = logging.ERROR
273   if options.debug:
274     lvl = logging.DEBUG
275   logging.basicConfig(level=lvl)
276   HomedirReminder().run()