]> git.decadent.org.uk Git - dak.git/blob - dak/clean_suites.py
dak/clean_suites.py: allow limiting file removal to specific archives
[dak.git] / dak / clean_suites.py
1 #!/usr/bin/env python
2
3 """ Cleans up unassociated binary and source packages
4
5 @contact: Debian FTPMaster <ftpmaster@debian.org>
6 @copyright: 2000, 2001, 2002, 2003, 2006  James Troup <james@nocrew.org>
7 @copyright: 2009  Mark Hymers <mhy@debian.org>
8 @copyright: 2010  Joerg Jaspert <joerg@debian.org>
9 @license: GNU General Public License version 2 or later
10 """
11
12 # This program is free software; you can redistribute it and/or modify
13 # it under the terms of the GNU General Public License as published by
14 # the Free Software Foundation; either version 2 of the License, or
15 # (at your option) any later version.
16
17 # This program is distributed in the hope that it will be useful,
18 # but WITHOUT ANY WARRANTY; without even the implied warranty of
19 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
20 # GNU General Public License for more details.
21
22 # You should have received a copy of the GNU General Public License
23 # along with this program; if not, write to the Free Software
24 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
25
26 ################################################################################
27
28 # 07:05|<elmo> well.. *shrug*.. no, probably not.. but to fix it,
29 #      |       we're going to have to implement reference counting
30 #      |       through dependencies.. do we really want to go down
31 #      |       that road?
32 #
33 # 07:05|<Culus> elmo: Augh! <brain jumps out of skull>
34
35 ################################################################################
36
37 import os
38 import stat
39 import sys
40 import time
41 import apt_pkg
42 from datetime import datetime, timedelta
43
44 from daklib.config import Config
45 from daklib.dbconn import *
46 from daklib import utils
47 from daklib import daklog
48
49 ################################################################################
50
51 Options = None
52 Logger = None
53
54 ################################################################################
55
56 def usage (exit_code=0):
57     print """Usage: dak clean-suites [OPTIONS]
58 Clean old packages from suites.
59
60   -n, --no-action            don't do anything
61   -h, --help                 show this help and exit
62   -m, --maximum              maximum number of files to remove"""
63     sys.exit(exit_code)
64
65 ################################################################################
66
67 def check_binaries(now_date, session):
68     print "Checking for orphaned binary packages..."
69
70     # Get the list of binary packages not in a suite and mark them for
71     # deletion.
72     # Check for any binaries which are marked for eventual deletion
73     # but are now used again.
74
75     query = """
76        WITH usage AS (
77          SELECT
78            af.archive_id AS archive_id,
79            af.file_id AS file_id,
80            af.component_id AS component_id,
81            BOOL_OR(EXISTS (SELECT 1 FROM bin_associations ba
82                             JOIN suite s ON ba.suite = s.id
83                            WHERE ba.bin = b.id
84                              AND s.archive_id = af.archive_id))
85              AS in_use
86          FROM files_archive_map af
87          JOIN binaries b ON af.file_id = b.file
88          GROUP BY af.archive_id, af.file_id, af.component_id
89        )
90
91        UPDATE files_archive_map af
92           SET last_used = CASE WHEN usage.in_use THEN NULL ELSE :last_used END
93          FROM usage, files f, archive
94         WHERE af.archive_id = usage.archive_id AND af.file_id = usage.file_id AND af.component_id = usage.component_id
95           AND ((af.last_used IS NULL AND NOT usage.in_use) OR (af.last_used IS NOT NULL AND usage.in_use))
96           AND af.file_id = f.id
97           AND af.archive_id = archive.id
98        RETURNING archive.name, f.filename, af.last_used IS NULL"""
99
100     res = session.execute(query, {'last_used': now_date})
101     for i in res:
102         op = "set lastused"
103         if i[2]:
104             op = "unset lastused"
105         Logger.log([op, i[0], i[1]])
106
107 ########################################
108
109 def check_sources(now_date, session):
110     print "Checking for orphaned source packages..."
111
112     # Get the list of source packages not in a suite and not used by
113     # any binaries.
114
115     #### XXX: this should ignore cases where the files for the binary b
116     ####      have been marked for deletion (so the delay between bins go
117     ####      byebye and sources go byebye is 0 instead of StayOfExecution)
118
119     # Check for any sources which are marked for deletion but which
120     # are now used again.
121
122     #### XXX: this should also handle deleted binaries specially (ie, not
123     ####      reinstate sources because of them
124
125     # TODO: the UPDATE part is the same as in check_binaries. Merge?
126
127     query = """
128     WITH usage AS (
129       SELECT
130         af.archive_id AS archive_id,
131         af.file_id AS file_id,
132         af.component_id AS component_id,
133         BOOL_OR(EXISTS (SELECT 1 FROM src_associations sa
134                          JOIN suite s ON sa.suite = s.id
135                         WHERE sa.source = df.source
136                           AND s.archive_id = af.archive_id)
137           OR EXISTS (SELECT 1 FROM files_archive_map af_bin
138                               JOIN binaries b ON af_bin.file_id = b.file
139                              WHERE b.source = df.source
140                                AND af_bin.archive_id = af.archive_id
141                                AND af_bin.last_used > ad.delete_date)
142           OR EXISTS (SELECT 1 FROM extra_src_references esr
143                          JOIN bin_associations ba ON esr.bin_id = ba.bin
144                          JOIN binaries b ON ba.bin = b.id
145                          JOIN suite s ON ba.suite = s.id
146                         WHERE esr.src_id = df.source
147                           AND s.archive_id = af.archive_id))
148           AS in_use
149       FROM files_archive_map af
150       JOIN dsc_files df ON af.file_id = df.file
151       JOIN archive_delete_date ad ON af.archive_id = ad.archive_id
152       GROUP BY af.archive_id, af.file_id, af.component_id
153     )
154
155     UPDATE files_archive_map af
156        SET last_used = CASE WHEN usage.in_use THEN NULL ELSE :last_used END
157       FROM usage, files f, archive
158      WHERE af.archive_id = usage.archive_id AND af.file_id = usage.file_id AND af.component_id = usage.component_id
159        AND ((af.last_used IS NULL AND NOT usage.in_use) OR (af.last_used IS NOT NULL AND usage.in_use))
160        AND af.file_id = f.id
161        AND af.archive_id = archive.id
162
163     RETURNING archive.name, f.filename, af.last_used IS NULL
164     """
165
166     res = session.execute(query, {'last_used': now_date})
167     for i in res:
168         op = "set lastused"
169         if i[2]:
170             op = "unset lastused"
171         Logger.log([op, i[0], i[1]])
172
173 ########################################
174
175 def check_files(now_date, session):
176     # FIXME: this is evil; nothing should ever be in this state.  if
177     # they are, it's a bug.
178
179     # However, we've discovered it happens sometimes so we print a huge warning
180     # and then mark the file for deletion.  This probably masks a bug somwhere
181     # else but is better than collecting cruft forever
182
183     print "Checking for unused files..."
184     q = session.execute("""
185     UPDATE files_archive_map af
186        SET last_used = :last_used
187       FROM files f, archive
188      WHERE af.file_id = f.id
189        AND af.archive_id = archive.id
190        AND NOT EXISTS (SELECT 1 FROM binaries b WHERE b.file = af.file_id)
191        AND NOT EXISTS (SELECT 1 FROM dsc_files df WHERE df.file = af.file_id)
192        AND af.last_used IS NULL
193     RETURNING archive.name, f.filename""", {'last_used': now_date})
194
195     for x in q:
196         utils.warn("orphaned file: {0}".format(x))
197         Logger.log(["set lastused", x[0], x[1], "ORPHANED FILE"])
198
199     if not Options["No-Action"]:
200         session.commit()
201
202 def clean_binaries(now_date, session):
203     # We do this here so that the binaries we remove will have their
204     # source also removed (if possible).
205
206     # XXX: why doesn't this remove the files here as well? I don't think it
207     #      buys anything keeping this separate
208
209     print "Deleting from binaries table... "
210     q = session.execute("""
211       DELETE FROM binaries b
212        USING files f
213        WHERE f.id = b.file
214          AND NOT EXISTS (SELECT 1 FROM files_archive_map af
215                                   JOIN archive_delete_date ad ON af.archive_id = ad.archive_id
216                                  WHERE af.file_id = b.file
217                                    AND (af.last_used IS NULL OR af.last_used >= ad.delete_date))
218       RETURNING f.filename
219     """)
220     for b in q:
221         Logger.log(["delete binary", b[0]])
222
223 ########################################
224
225 def clean(now_date, archives, max_delete, session):
226     cnf = Config()
227
228     count = 0
229     size = 0
230
231     print "Cleaning out packages..."
232
233     morguedir = cnf.get("Dir::Morgue", os.path.join("Dir::Pool", 'morgue'))
234     morguesubdir = cnf.get("Clean-Suites::MorgueSubDir", 'pool')
235
236     # Build directory as morguedir/morguesubdir/year/month/day
237     dest = os.path.join(morguedir,
238                         morguesubdir,
239                         str(now_date.year),
240                         '%.2d' % now_date.month,
241                         '%.2d' % now_date.day)
242
243     if not Options["No-Action"] and not os.path.exists(dest):
244         os.makedirs(dest)
245
246     # Delete from source
247     print "Deleting from source table... "
248     q = session.execute("""
249       WITH
250       deleted_sources AS (
251         DELETE FROM source
252          USING files f
253          WHERE source.file = f.id
254            AND NOT EXISTS (SELECT 1 FROM files_archive_map af
255                                     JOIN archive_delete_date ad ON af.archive_id = ad.archive_id
256                                    WHERE af.file_id = source.file
257                                      AND (af.last_used IS NULL OR af.last_used >= ad.delete_date))
258         RETURNING source.id AS id, f.filename AS filename
259       ),
260       deleted_dsc_files AS (
261         DELETE FROM dsc_files df WHERE df.source IN (SELECT id FROM deleted_sources)
262         RETURNING df.file AS file_id
263       ),
264       now_unused_source_files AS (
265         UPDATE files_archive_map af
266            SET last_used = '1977-03-13 13:37:42' -- Kill it now. We waited long enough before removing the .dsc.
267          WHERE af.file_id IN (SELECT file_id FROM deleted_dsc_files)
268            AND NOT EXISTS (SELECT 1 FROM dsc_files df WHERE df.file = af.file_id)
269       )
270       SELECT filename FROM deleted_sources""")
271     for s in q:
272         Logger.log(["delete source", s[0]])
273
274     if not Options["No-Action"]:
275         session.commit()
276
277     # Delete files from the pool
278     old_files = session.query(ArchiveFile).filter('files_archive_map.last_used <= (SELECT delete_date FROM archive_delete_date ad WHERE ad.archive_id = files_archive_map.archive_id)').join(Archive)
279     if max_delete is not None:
280         old_files = old_files.limit(max_delete)
281         print "Limiting removals to %d" % max_delete
282
283     if archives is not None:
284         archive_ids = [ a.archive_id for a in archives ]
285         old_files = old_files.filter(ArchiveFile.archive_id.in_(archive_ids))
286
287     for af in old_files:
288         filename = af.path
289         if not os.path.exists(filename):
290             Logger.log(["database referred to non-existing file", af.path])
291             session.delete(af)
292             continue
293         Logger.log(["delete archive file", filename])
294         if os.path.isfile(filename):
295             if os.path.islink(filename):
296                 count += 1
297                 Logger.log(["delete symlink", filename])
298                 if not Options["No-Action"]:
299                     os.unlink(filename)
300             else:
301                 size += os.stat(filename)[stat.ST_SIZE]
302                 count += 1
303
304                 dest_filename = dest + '/' + os.path.basename(filename)
305                 # If the destination file exists; try to find another filename to use
306                 if os.path.exists(dest_filename):
307                     dest_filename = utils.find_next_free(dest_filename)
308
309                 if not Options["No-Action"]:
310                     if af.archive.use_morgue:
311                         Logger.log(["move to morgue", filename, dest_filename])
312                         utils.move(filename, dest_filename)
313                     else:
314                         Logger.log(["removed file", filename])
315                         os.unlink(filename)
316
317             if not Options["No-Action"]:
318                 session.delete(af)
319                 session.commit()
320
321         else:
322             utils.fubar("%s is neither symlink nor file?!" % (filename))
323
324     if count > 0:
325         Logger.log(["total", count, utils.size_type(size)])
326         print "Cleaned %d files, %s." % (count, utils.size_type(size))
327
328     # Delete entries in files no longer referenced by any archive
329     query = """
330        DELETE FROM files f
331         WHERE NOT EXISTS (SELECT 1 FROM files_archive_map af WHERE af.file_id = f.id)
332     """
333     session.execute(query)
334
335     if not Options["No-Action"]:
336         session.commit()
337
338 ################################################################################
339
340 def clean_maintainers(now_date, session):
341     print "Cleaning out unused Maintainer entries..."
342
343     # TODO Replace this whole thing with one SQL statement
344     q = session.execute("""
345 SELECT m.id, m.name FROM maintainer m
346   WHERE NOT EXISTS (SELECT 1 FROM binaries b WHERE b.maintainer = m.id)
347     AND NOT EXISTS (SELECT 1 FROM source s WHERE s.maintainer = m.id OR s.changedby = m.id)
348     AND NOT EXISTS (SELECT 1 FROM src_uploaders u WHERE u.maintainer = m.id)""")
349
350     count = 0
351
352     for i in q.fetchall():
353         maintainer_id = i[0]
354         Logger.log(["delete maintainer", i[1]])
355         if not Options["No-Action"]:
356             session.execute("DELETE FROM maintainer WHERE id = :maint", {'maint': maintainer_id})
357         count += 1
358
359     if not Options["No-Action"]:
360         session.commit()
361
362     if count > 0:
363         Logger.log(["total", count])
364         print "Cleared out %d maintainer entries." % (count)
365
366 ################################################################################
367
368 def clean_fingerprints(now_date, session):
369     print "Cleaning out unused fingerprint entries..."
370
371     # TODO Replace this whole thing with one SQL statement
372     q = session.execute("""
373 SELECT f.id, f.fingerprint FROM fingerprint f
374   WHERE f.keyring IS NULL
375     AND NOT EXISTS (SELECT 1 FROM binaries b WHERE b.sig_fpr = f.id)
376     AND NOT EXISTS (SELECT 1 FROM source s WHERE s.sig_fpr = f.id)""")
377
378     count = 0
379
380     for i in q.fetchall():
381         fingerprint_id = i[0]
382         Logger.log(["delete fingerprint", i[1]])
383         if not Options["No-Action"]:
384             session.execute("DELETE FROM fingerprint WHERE id = :fpr", {'fpr': fingerprint_id})
385         count += 1
386
387     if not Options["No-Action"]:
388         session.commit()
389
390     if count > 0:
391         Logger.log(["total", count])
392         print "Cleared out %d fingerprint entries." % (count)
393
394 ################################################################################
395
396 def clean_empty_directories(session):
397     """
398     Removes empty directories from pool directories.
399     """
400
401     print "Cleaning out empty directories..."
402
403     count = 0
404
405     cursor = session.execute(
406         """SELECT DISTINCT(path) FROM archive"""
407     )
408     bases = [x[0] for x in cursor.fetchall()]
409
410     for base in bases:
411         for dirpath, dirnames, filenames in os.walk(base, topdown=False):
412             if not filenames and not dirnames:
413                 to_remove = os.path.join(base, dirpath)
414                 if not Options["No-Action"]:
415                     Logger.log(["removing directory", to_remove])
416                     os.removedirs(to_remove)
417                 count += 1
418
419     if count:
420         Logger.log(["total removed directories", count])
421
422 ################################################################################
423
424 def set_archive_delete_dates(now_date, session):
425     session.execute("""
426         CREATE TEMPORARY TABLE archive_delete_date (
427           archive_id INT NOT NULL,
428           delete_date TIMESTAMP NOT NULL
429         )""")
430
431     session.execute("""
432         INSERT INTO archive_delete_date
433           (archive_id, delete_date)
434         SELECT
435           archive.id, :now_date - archive.stayofexecution
436         FROM archive""", {'now_date': now_date})
437
438     session.flush()
439
440 ################################################################################
441
442 def main():
443     global Options, Logger
444
445     cnf = Config()
446
447     for i in ["Help", "No-Action", "Maximum" ]:
448         if not cnf.has_key("Clean-Suites::Options::%s" % (i)):
449             cnf["Clean-Suites::Options::%s" % (i)] = ""
450
451     Arguments = [('h',"help","Clean-Suites::Options::Help"),
452                  ('a','archive','Clean-Suites::Options::Archive','HasArg'),
453                  ('n',"no-action","Clean-Suites::Options::No-Action"),
454                  ('m',"maximum","Clean-Suites::Options::Maximum", "HasArg")]
455
456     apt_pkg.parse_commandline(cnf.Cnf, Arguments, sys.argv)
457     Options = cnf.subtree("Clean-Suites::Options")
458
459     if cnf["Clean-Suites::Options::Maximum"] != "":
460         try:
461             # Only use Maximum if it's an integer
462             max_delete = int(cnf["Clean-Suites::Options::Maximum"])
463             if max_delete < 1:
464                 utils.fubar("If given, Maximum must be at least 1")
465         except ValueError as e:
466             utils.fubar("If given, Maximum must be an integer")
467     else:
468         max_delete = None
469
470     if Options["Help"]:
471         usage()
472
473     Logger = daklog.Logger("clean-suites", debug=Options["No-Action"])
474
475     session = DBConn().session()
476
477     archives = None
478     if 'Archive' in Options:
479         archive_names = Options['Archive'].split(',')
480         archives = session.query(Archive).filter(Archive.archive_name.in_(archive_names)).all()
481         if len(archives) == 0:
482             utils.fubar('Unknown archive.')
483
484     now_date = datetime.now()
485
486     set_archive_delete_dates(now_date, session)
487
488     check_binaries(now_date, session)
489     clean_binaries(now_date, session)
490     check_sources(now_date, session)
491     check_files(now_date, session)
492     clean(now_date, archives, max_delete, session)
493     clean_maintainers(now_date, session)
494     clean_fingerprints(now_date, session)
495     clean_empty_directories(session)
496
497     session.rollback()
498
499     Logger.close()
500
501 ################################################################################
502
503 if __name__ == '__main__':
504     main()