73370e3e2ddcb888a8c3348870cbd5a45622f358
[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     if platform.architecture()[0] == "32bit":
127         self.LASTLOG_STRUCT = self.LASTLOG_STRUCT_32
128         record_size = record_size_32
129     elif platform.architecture()[0] == "64bit":
130         self.LASTLOG_STRUCT = self.LASTLOG_STRUCT_64
131         record_size = record_size_64
132     else:
133         raise RuntimeError('Unknown architecture, cannot interpret platform.architecture()[0] value (%d)' % platform.architecture()[0])
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         uid += 1 # so keep incrementing uid for each record read
138         lastlog_time, _, _ = list(struct.unpack(self.LASTLOG_STRUCT, record))
139         try:
140           self[pwd.getpwuid(uid).pw_name] = lastlog_time
141         except KeyError:
142           # this is a normal condition
143           continue
144
145 class HomedirSizes(dict):
146   def __init__(self):
147     for direntry in glob.glob('/home/*'):
148       username = os.path.basename(direntry)
149
150       if username in EXCLUDED_USERNAMES:
151         continue
152
153       try:
154         pwinfo = pwd.getpwnam(username)
155       except KeyError:
156         if os.path.isdir(direntry):
157           logging.warning('directory %s exists on %s but there is no %s user', direntry, platform.node(), username)
158         continue
159
160       if pwinfo.pw_dir != direntry:
161         logging.warning('home directory for %s is not %s, but that exists.  confused.', username, direntry)
162         continue
163
164       command = ['/usr/bin/du', '-ms', pwinfo.pw_dir]
165       p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
166       (stdout, stderr) = p.communicate()
167       if p.returncode != 0: # ignore errors from du
168         logging.info('%s failed:', ' '.join(command))
169         logging.info(stderr)
170         continue
171       try:
172         self[username] = int(stdout.split('\t')[0])
173       except ValueError:
174         logging.error('could not convert size output from %s: %s', ' '.join(command), stdout)
175         continue
176
177 class HomedirReminder(object):
178   def __init__(self):
179     self.lastlog_times = LastlogTimes()
180     self.homedir_sizes = HomedirSizes()
181
182   def notify(self, **kwargs):
183     msg = email.mime.text.MIMEText(MAIL_MESSAGE.format(**kwargs), _charset='UTF-8')
184     msg['From'] = MAIL_FROM.format(**kwargs)
185     msg['To'] = MAIL_TO.format(**kwargs)
186     if MAIL_CC != "":
187       msg['Cc'] = MAIL_CC.format(**kwargs)
188     if MAIL_REPLYTO != "":
189       msg['Reply-To'] = MAIL_REPLYTO.format(**kwargs)
190     msg['Subject'] = MAIL_SUBJECT.format(**kwargs)
191     msg['Precedence'] = "bulk"
192     msg['Auto-Submitted'] = "auto-generated by mail-big-homedirs"
193     p = subprocess.Popen(SENDMAIL_COMMAND, stdin=subprocess.PIPE)
194     p.communicate(msg.as_string())
195     logging.debug(msg.as_string())
196     if p.returncode != 0:
197       raise SendmailError
198
199   def remove(self, **kwargs):
200     try:
201       pwinfo = pwd.getpwnam(kwargs.get('username'))
202     except KeyError:
203       return
204
205     command = RM_COMMAND + [pwinfo.pw_dir]
206     p = subprocess.check_call(command)
207
208   def run(self):
209     current_time = time.time()
210     conn = None
211     try:
212       data = {}
213       for user in set(self.homedir_sizes.keys()) | \
214                   set(self.lastlog_times.keys()):
215         data[user] = {
216           'homedir': self.homedir_sizes.get(user, 0),
217           'lastlog': self.lastlog_times.get(user, 0),
218         }
219
220       if mq_config is not None:
221         msg = {
222           'timestamp': current_time,
223           'data': data,
224           'host': platform.node(),
225         }
226         conn = Connection(conf=mq_conf)
227         conn.topic_send(mq_config.topic,
228                         msg,
229                         exchange_name=mq_config.exchange,
230                         timeout=5)
231     except Exception, e:
232       logging.error("Error sending: %s" % e)
233     finally:
234       if conn:
235         conn.close()
236
237     for username, homedir_size in self.homedir_sizes.iteritems():
238       try:
239         realname = pwd.getpwnam(username).pw_gecos.decode('utf-8').split(',', 1)[0]
240       except:
241         realname = username
242       lastlog_time = self.lastlog_times.get(username, 0)
243       days_ago = int( (current_time - lastlog_time) / 3600 / 24 )
244       kwargs = {
245           'hostname': platform.node(),
246           'username': username,
247           'realname': realname,
248           'homedir_size': homedir_size,
249           'days_ago': days_ago
250         }
251
252       notify = False
253       remove = False
254       for x in CRITERIA:
255         if homedir_size > x['size'] and 'notifyafter' in x and days_ago >= x['notifyafter']:
256           notify = True
257         if homedir_size > x['size'] and 'deleteafter' in x and days_ago >= x['deleteafter']:
258           remove = True
259
260       if remove:
261         self.remove(**kwargs)
262       elif notify:
263         self.notify(**kwargs)
264
265 if __name__ == '__main__':
266   lvl = logging.ERROR
267   if options.debug:
268     lvl = logging.DEBUG
269   logging.basicConfig(level=lvl)
270   HomedirReminder().run()