b4c36636ec4355b3d9cec02d98d6ae91aac7131b
[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 sysconfig
42 import StringIO
43
44 # avoid base64 encoding for utf-8
45 email.charset.add_charset('utf-8', email.charset.SHORTEST, email.charset.QP)
46
47 parser = OptionParser()
48 parser.add_option("-D", "--dryrun",
49                   action="store_true", default=False,
50                   help="Dry run mode")
51
52 parser.add_option("-d", "--debug",
53                   action="store_true", default=False,
54                   help="Enable debug output")
55
56 (options, args) = parser.parse_args()
57 options.section = 'dsa-homedirs'
58 options.config = '/etc/dsa/pubsub.conf'
59 if os.access(options.config, os.R_OK):
60   mq_config = Config(options)
61   mq_conf  = {
62     'rabbit_userid': mq_config.username,
63     'rabbit_password': mq_config.password,
64     'rabbit_virtual_host': mq_config.vhost,
65     'rabbit_hosts': ['pubsub02.debian.org', 'pubsub01.debian.org'],
66     'use_ssl': False
67   }
68 else:
69   mq_config = None
70
71 if options.dryrun:
72   SENDMAIL_COMMAND = ['/bin/cat']
73   RM_COMMAND = ['/bin/echo', 'Would remove']
74 else:
75   SENDMAIL_COMMAND = ['/usr/sbin/sendmail', '-t', '-oi']
76   RM_COMMAND = ['/bin/rm', '-rf']
77
78 CRITERIA = [
79     { 'size': 10240,  'notifyafter':  5, 'deleteafter':  40 },
80     { 'size':  1024,  'notifyafter': 10, 'deleteafter':  50 },
81     { 'size':   100,  'notifyafter': 30, 'deleteafter':  90 },
82     { 'size':    20,  'notifyafter': 90, 'deleteafter': 150 },
83     { 'size':     5,                     'deleteafter': 700 }
84   ]
85 EXCLUDED_USERNAMES = ['lost+found', 'debian', 'buildd', 'd-i']
86 MAIL_FROM = 'debian-admin (via Cron) <bulk@admin.debian.org>'
87 MAIL_TO = '{username}@{hostname}.debian.org'
88 MAIL_CC = 'debian-admin (bulk sink) <bulk@admin.debian.org>'
89 MAIL_REPLYTO = 'debian-admin <dsa@debian.org>'
90 MAIL_SUBJECT = 'Please clean up ~{username} on {hostname}.debian.org'
91 MAIL_MESSAGE = u"""\
92 Hi {realname}!
93
94 Thanks for your porting effort on {hostname}!
95
96 Please note that, on most porterboxes, /home is quite small, so please
97 remove files that you do not need anymore.
98
99 For your information, you last logged into {hostname} {days_ago} days
100 ago, and your home directory there is {homedir_size} MB in size.
101
102 If you currently do not use {hostname}, please keep ~{username} under
103 10 MB, if possible.
104
105 Please assist us in freeing up space by deleting schroots, also.
106
107 Thanks,
108
109 Debian System Administration Team via Cron
110
111 PS: A reply is not required.
112 """
113
114 class Error(Exception):
115   pass
116
117 class SendmailError(Error):
118   pass
119
120 class LastlogTimes(dict):
121   LASTLOG_STRUCT_32 = '=L32s256s'
122   LASTLOG_STRUCT_64 = '=Q32s256s'
123
124   def __init__(self):
125     record_size_32 = struct.calcsize(self.LASTLOG_STRUCT_32)
126     record_size_64 = struct.calcsize(self.LASTLOG_STRUCT_64)
127     if sysconfig.get_config_var('SIZEOF_TIME_T') == 4
128         self.LASTLOG_STRUCT = self.LASTLOG_STRUCT_32
129         record_size = record_size_32
130     elif sysconfig.get_config_var('SIZEOF_TIME_T') == 8:
131         self.LASTLOG_STRUCT = self.LASTLOG_STRUCT_64
132         record_size = record_size_64
133     else:
134         raise RuntimeError('Unknown architecture, sizeof time_t is weird (%d)' % (sysconfig.get_config_var('SIZEOF_TIME_T'),))
135     with open('/var/log/lastlog', 'r') as fp:
136       uid = -1 # there is one record per uid in lastlog
137       for record in iter(lambda: fp.read(record_size), ''):
138         uid += 1 # so keep incrementing uid for each record read
139         lastlog_time, _, _ = list(struct.unpack(self.LASTLOG_STRUCT, record))
140         try:
141           self[pwd.getpwuid(uid).pw_name] = lastlog_time
142         except KeyError:
143           # this is a normal condition
144           continue
145
146 class HomedirSizes(dict):
147   def __init__(self):
148     for direntry in glob.glob('/home/*'):
149       username = os.path.basename(direntry)
150
151       if username in EXCLUDED_USERNAMES:
152         continue
153
154       try:
155         pwinfo = pwd.getpwnam(username)
156       except KeyError:
157         if os.path.isdir(direntry):
158           logging.warning('directory %s exists on %s but there is no %s user', direntry, platform.node(), username)
159         continue
160
161       if pwinfo.pw_dir != direntry:
162         logging.warning('home directory for %s is not %s, but that exists.  confused.', username, direntry)
163         continue
164
165       command = ['/usr/bin/du', '-ms', pwinfo.pw_dir]
166       p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
167       (stdout, stderr) = p.communicate()
168       if p.returncode != 0: # ignore errors from du
169         logging.info('%s failed:', ' '.join(command))
170         logging.info(stderr)
171         continue
172       try:
173         self[username] = int(stdout.split('\t')[0])
174       except ValueError:
175         logging.error('could not convert size output from %s: %s', ' '.join(command), stdout)
176         continue
177
178 class HomedirReminder(object):
179   def __init__(self):
180     self.lastlog_times = LastlogTimes()
181     self.homedir_sizes = HomedirSizes()
182
183   def notify(self, **kwargs):
184     msg = email.mime.text.MIMEText(MAIL_MESSAGE.format(**kwargs), _charset='UTF-8')
185     msg['From'] = MAIL_FROM.format(**kwargs)
186     msg['To'] = MAIL_TO.format(**kwargs)
187     if MAIL_CC != "":
188       msg['Cc'] = MAIL_CC.format(**kwargs)
189     if MAIL_REPLYTO != "":
190       msg['Reply-To'] = MAIL_REPLYTO.format(**kwargs)
191     msg['Subject'] = MAIL_SUBJECT.format(**kwargs)
192     msg['Precedence'] = "bulk"
193     msg['Auto-Submitted'] = "auto-generated by mail-big-homedirs"
194     p = subprocess.Popen(SENDMAIL_COMMAND, stdin=subprocess.PIPE)
195     p.communicate(msg.as_string())
196     logging.debug(msg.as_string())
197     if p.returncode != 0:
198       raise SendmailError
199
200   def remove(self, **kwargs):
201     try:
202       pwinfo = pwd.getpwnam(kwargs.get('username'))
203     except KeyError:
204       return
205
206     command = RM_COMMAND + [pwinfo.pw_dir]
207     p = subprocess.check_call(command)
208
209   def run(self):
210     current_time = time.time()
211     conn = None
212     try:
213       data = {}
214       for user in set(self.homedir_sizes.keys()) | \
215                   set(self.lastlog_times.keys()):
216         data[user] = {
217           'homedir': self.homedir_sizes.get(user, 0),
218           'lastlog': self.lastlog_times.get(user, 0),
219         }
220
221       if mq_config is not None:
222         msg = {
223           'timestamp': current_time,
224           'data': data,
225           'host': platform.node(),
226         }
227         conn = Connection(conf=mq_conf)
228         conn.topic_send(mq_config.topic,
229                         msg,
230                         exchange_name=mq_config.exchange,
231                         timeout=5)
232     except Exception, e:
233       logging.error("Error sending: %s" % e)
234     finally:
235       if conn:
236         conn.close()
237
238     for username, homedir_size in self.homedir_sizes.iteritems():
239       try:
240         realname = pwd.getpwnam(username).pw_gecos.decode('utf-8').split(',', 1)[0]
241       except:
242         realname = username
243       lastlog_time = self.lastlog_times.get(username, 0)
244       days_ago = int( (current_time - lastlog_time) / 3600 / 24 )
245       kwargs = {
246           'hostname': platform.node(),
247           'username': username,
248           'realname': realname,
249           'homedir_size': homedir_size,
250           'days_ago': days_ago
251         }
252
253       notify = False
254       remove = False
255       for x in CRITERIA:
256         if homedir_size > x['size'] and 'notifyafter' in x and days_ago >= x['notifyafter']:
257           notify = True
258         if homedir_size > x['size'] and 'deleteafter' in x and days_ago >= x['deleteafter']:
259           remove = True
260
261       if remove:
262         self.remove(**kwargs)
263       elif notify:
264         self.notify(**kwargs)
265
266 if __name__ == '__main__':
267   lvl = logging.ERROR
268   if options.debug:
269     lvl = logging.DEBUG
270   logging.basicConfig(level=lvl)
271   HomedirReminder().run()