]> git.ipfire.org Git - ipfire.org.git/blame - webapp/backend/mirrors.py
Rewrite geo load balancing.
[ipfire.org.git] / webapp / backend / mirrors.py
CommitLineData
940227cb
MT
1#!/usr/bin/python
2
9068dba1
MT
3from __future__ import division
4
5import datetime
940227cb 6import logging
0673d1b0 7import math
54af860e 8import os.path
0673d1b0 9import random
940227cb
MT
10import socket
11import time
12import tornado.httpclient
9068dba1
MT
13import tornado.netutil
14import urlparse
940227cb 15
9068dba1 16from misc import Object
60024cc8 17
9068dba1 18class Downloads(Object):
60024cc8
MT
19 @property
20 def total(self):
21 ret = self.db.get("SELECT COUNT(*) AS total FROM log_download")
22
23 return ret.total
24
25 @property
26 def today(self):
9068dba1 27 ret = self.db.get("SELECT COUNT(*) AS today FROM log_download WHERE date::date = NOW()::date")
60024cc8
MT
28
29 return ret.today
30
31 @property
32 def yesterday(self):
9068dba1 33 ret = self.db.get("SELECT COUNT(*) AS yesterday FROM log_download WHERE date::date = (NOW() - INTERVAL '1 day')::date")
60024cc8
MT
34
35 return ret.yesterday
36
37 @property
38 def daily_map(self):
9068dba1
MT
39 ret = self.db.query("SELECT date::date AS date, COUNT(*) AS downloads FROM log_download"
40 " WHERE date::date BETWEEN (NOW() - INTERVAL '30 days')::date AND NOW()::date GROUP BY date::date")
60024cc8
MT
41
42 return ret
43
44 def get_countries(self, duration="all"):
45 query = "SELECT country_code, count(country_code) AS count FROM log_download"
46
47 if duration == "today":
9068dba1 48 query += " WHERE date::date = NOW()::date"
60024cc8
MT
49
50 query += " GROUP BY country_code ORDER BY count DESC"
51
52 results = self.db.query(query)
53 ret = {}
54
55 count = sum([o.count for o in results])
9068dba1
MT
56 if count:
57 for res in results:
58 ret[res.country_code] = res.count / count
60024cc8
MT
59
60 return ret
61
62 def get_mirror_load(self, duration="all"):
63 query = "SELECT mirror, COUNT(mirror) AS count FROM log_download"
64
65 if duration == "today":
9068dba1 66 query += " WHERE date::date = NOW()::date"
60024cc8
MT
67
68 query += " GROUP BY mirror ORDER BY count DESC"
69
70 results = self.db.query(query)
71 ret = {}
72
73 count = sum([o.count for o in results])
9068dba1
MT
74 if count:
75 for res in results:
76 mirror = self.mirrors.get(res.mirror)
77 ret[mirror.hostname] = res.count / count
60024cc8
MT
78
79 return ret
80
81
9068dba1 82class Mirrors(Object):
940227cb 83 def check_all(self):
9068dba1 84 for mirror in self.get_all():
940227cb
MT
85 mirror.check()
86
87 def get(self, id):
9068dba1 88 return Mirror(self.backend, id)
940227cb 89
0673d1b0 90 def get_all(self):
9068dba1
MT
91 res = self.db.query("SELECT * FROM mirrors WHERE enabled = %s", True)
92
93 mirrors = []
94 for row in res:
95 mirror = Mirror(self.backend, row.id, row)
96 mirrors.append(mirror)
97
98 return MirrorSet(self.backend, sorted(mirrors))
0673d1b0 99
bd17b7d1 100 def get_all_up(self):
9068dba1
MT
101 res = self.db.query("SELECT * FROM mirrors WHERE enabled = %s AND state = %s \
102 ORDER BY hostname", True, "UP")
bd17b7d1
MT
103
104 mirrors = []
105 for row in res:
9068dba1 106 m = Mirror(self.backend, row.id, row)
bd17b7d1
MT
107 mirrors.append(m)
108
9068dba1 109 return MirrorSet(self.backend, mirrors)
bd17b7d1 110
940227cb 111 def get_by_hostname(self, hostname):
5488a9f4 112 ret = self.db.get("SELECT * FROM mirrors WHERE hostname = %s", hostname)
940227cb 113
5488a9f4
MT
114 if ret:
115 return Mirror(self.backend, ret.id, ret)
940227cb 116
54af860e
MT
117 def get_with_file(self, filename, country=None):
118 # XXX quick and dirty solution - needs a performance boost
119 mirror_ids = [m.mirror for m in self.db.query("SELECT mirror FROM mirror_files WHERE filename=%s", filename)]
120
121 #if country:
122 # # Sort out all mirrors that are not preferred to the given country
123 # for mirror in self.get_for_country(country):
124 # if not mirror.id in mirror_ids:
125 # mirror_ids.remove(mirror.id)
126
127 mirrors = []
128 for mirror_id in mirror_ids:
129 mirror = self.get(mirror_id)
130 if not mirror.state == "UP":
131 continue
132 mirrors.append(mirror)
133
54af860e
MT
134 return mirrors
135
5488a9f4 136 def get_for_location(self, location, max_distance=4000, filename=None):
119f55d7 137 if not location:
5488a9f4
MT
138 return []
139
140 if filename:
141 res = self.db.query("\
142 WITH client AS (SELECT point(%s, %s) AS location) \
143 SELECT * FROM mirrors WHERE mirrors.state = %s \
144 AND mirrors.id IN ( \
145 SELECT mirror FROM mirror_files WHERE filename = %s \
146 ) AND mirrors.id IN ( \
147 SELECT id FROM mirrors_locations, client \
148 WHERE geodistance(mirrors_locations.location, client.location) <= %s \
149 )",
150 location.latitude, location.longitude, "UP", filename, max_distance)
151 else:
152 res = self.db.query("\
153 WITH client AS (SELECT point(%s, %s) AS location) \
154 SELECT * FROM mirrors WHERE mirrors.state = %s AND mirrors.id IN ( \
155 SELECT id FROM mirrors_locations, client \
156 WHERE geodistance(mirrors_locations.location, client.location) <= %s \
157 )",
158 location.latitude, location.longitude, "UP", max_distance)
9068dba1
MT
159
160 mirrors = []
5488a9f4
MT
161 for row in res:
162 mirror = Mirror(self.backend, row.id, row)
163 mirrors.append(mirror)
0673d1b0 164
5488a9f4 165 return sorted(mirrors)
0673d1b0 166
edd297c4
MT
167 def get_all_files(self):
168 files = []
169
9068dba1 170 for mirror in self.get_all():
edd297c4
MT
171 if not mirror.state == "UP":
172 continue
173
174 for file in mirror.filelist:
175 if not file in files:
176 files.append(file)
177
178 return files
179
5488a9f4
MT
180 def get_random(self, filename=None):
181 if filename:
182 ret = self.db.get("SELECT * FROM mirrors WHERE state = %s \
183 AND mirrors.id IN (SELECT mirror FROM mirror_files \
184 WHERE filename = %s) ORDER BY RANDOM() LIMIT 1", "UP", filename)
185 else:
186 ret = self.db.get("SELECT * FROM mirrors WHERE state = %s \
187 ORDER BY RANDOM() LIMIT 1", "UP")
188
189 if ret:
190 return Mirror(self.backend, ret.id, ret)
191
192 def file_exists(self, filename):
193 ret = self.db.get("SELECT 1 FROM mirror_files \
194 WHERE filename = %s LIMIT 1", filename)
195
196 if ret:
197 return True
198
199 return False
200
940227cb 201
9068dba1
MT
202class MirrorSet(Object):
203 def __init__(self, backend, mirrors):
204 Object.__init__(self, backend)
205
0673d1b0
MT
206 self._mirrors = mirrors
207
208 def __add__(self, other):
209 mirrors = []
210
211 for mirror in self._mirrors + other._mirrors:
212 if mirror in mirrors:
213 continue
214
215 mirrors.append(mirror)
216
9068dba1 217 return MirrorSet(self.backend, mirrors)
0673d1b0
MT
218
219 def __sub__(self, other):
220 mirrors = self._mirrors[:]
221
222 for mirror in other._mirrors:
223 if mirror in mirrors:
224 mirrors.remove(mirror)
225
9068dba1 226 return MirrorSet(self.backend, mirrors)
0673d1b0
MT
227
228 def __iter__(self):
229 return iter(self._mirrors)
230
231 def __len__(self):
232 return len(self._mirrors)
233
234 def __str__(self):
235 return "<MirrorSet %s>" % ", ".join([m.hostname for m in self._mirrors])
236
0673d1b0
MT
237 def get_with_file(self, filename):
238 with_file = [m.mirror for m in self.db.query("SELECT mirror FROM mirror_files WHERE filename=%s", filename)]
239
240 mirrors = []
241 for mirror in self._mirrors:
242 if mirror.id in with_file:
243 mirrors.append(mirror)
244
9068dba1 245 return MirrorSet(self.backend, mirrors)
0673d1b0
MT
246
247 def get_random(self):
248 mirrors = []
249 for mirror in self._mirrors:
f1f7eb7e 250 for i in range(0, mirror.priority):
0673d1b0
MT
251 mirrors.append(mirror)
252
253 return random.choice(mirrors)
254
9068dba1
MT
255 def get_for_location(self, location):
256 distance = 2500
0673d1b0
MT
257 mirrors = []
258
119f55d7 259 if location:
9068dba1 260 while len(mirrors) <= 3 and distance <= 8000:
119f55d7
MT
261 for mirror in self._mirrors:
262 if mirror in mirrors:
263 continue
0673d1b0 264
9068dba1
MT
265 mirror_distance = mirror.distance_to(location)
266 if mirror_distance is None:
267 continue
268
269 if mirror_distance <= distance:
119f55d7 270 mirrors.append(mirror)
0673d1b0 271
119f55d7 272 distance *= 1.2
0673d1b0 273
9068dba1 274 return MirrorSet(self.backend, mirrors)
0673d1b0
MT
275
276 def get_with_state(self, state):
277 mirrors = []
278
279 for mirror in self._mirrors:
280 if mirror.state == state:
281 mirrors.append(mirror)
282
9068dba1
MT
283 return MirrorSet(self.backend, mirrors)
284
0673d1b0 285
9068dba1
MT
286class Mirror(Object):
287 def __init__(self, backend, id, data=None):
288 Object.__init__(self, backend)
0673d1b0 289
940227cb
MT
290 self.id = id
291
bd17b7d1
MT
292 if data:
293 self._info = data
294 else:
295 self._info = self.db.get("SELECT * FROM mirrors WHERE id = %s", self.id)
296 self._info["url"] = self.generate_url()
940227cb 297
119f55d7
MT
298 self.__location = None
299 self.__country_name = None
300
54af860e
MT
301 def __repr__(self):
302 return "<%s %s>" % (self.__class__.__name__, self.url)
303
304 def __cmp__(self, other):
9068dba1 305 ret = cmp(self.country_code, other.country_code)
54af860e 306
9068dba1
MT
307 if not ret:
308 ret = cmp(self.hostname, other.hostname)
309
310 return ret
940227cb 311
940227cb
MT
312 def generate_url(self):
313 url = "http://%s" % self.hostname
314 if not self.path.startswith("/"):
315 url += "/"
316 url += "%s" % self.path
317 if not self.path.endswith("/"):
318 url += "/"
319 return url
320
9068dba1
MT
321 @property
322 def hostname(self):
323 return self._info.hostname
324
325 @property
326 def path(self):
327 return self._info.path
940227cb
MT
328
329 @property
330 def address(self):
b898caea
MT
331 for addr in self.addresses4:
332 return addr
333
334 for addr in self.addresses6:
335 return addr
940227cb 336
9068dba1
MT
337 @property
338 def owner(self):
339 return self._info.owner
340
0673d1b0
MT
341 @property
342 def location(self):
119f55d7 343 if self.__location is None:
9068dba1 344 self.__location = self.geoip.get_location(self.address)
0673d1b0
MT
345
346 return self.__location
347
348 @property
349 def latitude(self):
9068dba1
MT
350 if self.location:
351 return self.location.latitude
0673d1b0
MT
352
353 @property
354 def longitude(self):
9068dba1
MT
355 if self.location:
356 return self.location.longitude
0673d1b0
MT
357
358 @property
359 def coordinates(self):
360 return (self.latitude, self.longitude)
361
362 @property
363 def coordiante_str(self):
364 coordinates = []
365
366 for i in self.coordinates:
367 coordinates.append("%s" % i)
368
369 return ",".join(coordinates)
370
940227cb
MT
371 @property
372 def country_code(self):
9068dba1
MT
373 if self.location:
374 return self.location.country
940227cb 375
0673d1b0
MT
376 @property
377 def country_name(self):
119f55d7 378 if self.__country_name is None:
9068dba1 379 self.__country_name = self.geoip.get_country_name(self.country_code)
119f55d7
MT
380
381 return self.__country_name
0673d1b0
MT
382
383 @property
9068dba1
MT
384 def location_str(self):
385 location = []
386
387 if self._info.location:
388 location.append(self._info.location)
0673d1b0 389
9068dba1
MT
390 elif self.location:
391 location.append(self.location.city)
392 location.append(self.country_name)
393
394 return ", ".join([s for s in location if s])
0673d1b0
MT
395
396 @property
9068dba1
MT
397 def asn(self):
398 if not hasattr(self, "__asn"):
399 self.__asn = self.geoip.get_asn(self.address)
0673d1b0 400
9068dba1 401 return self.__asn
0673d1b0 402
940227cb
MT
403 @property
404 def filelist(self):
405 filelist = self.db.query("SELECT filename FROM mirror_files WHERE mirror=%s ORDER BY filename", self.id)
406 return [f.filename for f in filelist]
407
54af860e
MT
408 @property
409 def prefix(self):
54af860e
MT
410 return ""
411
9068dba1
MT
412 @property
413 def url(self):
414 return self._info.url
415
416 def build_url(self, filename):
417 return urlparse.urljoin(self.url, filename)
418
419 @property
420 def last_update(self):
421 return self._info.last_update
422
423 @property
424 def state(self):
425 return self._info.state
426
940227cb
MT
427 def set_state(self, state):
428 logging.info("Setting state of %s to %s" % (self.hostname, state))
429
430 if self.state == state:
431 return
432
9068dba1 433 self.db.execute("UPDATE mirrors SET state = %s WHERE id = %s", state, self.id)
940227cb
MT
434
435 # Reload changed settings
bd17b7d1
MT
436 if hasattr(self, "_info"):
437 self._info["state"] = state
940227cb 438
9068dba1
MT
439 @property
440 def enabled(self):
441 return self._info.enabled
442
443 @property
444 def disabled(self):
445 return not self.enabled
446
940227cb
MT
447 def check(self):
448 logging.info("Running check for mirror %s" % self.hostname)
449
450 self.check_timestamp()
451 self.check_filelist()
452
453 def check_state(self):
454 logging.debug("Checking state of mirror %s" % self.id)
455
9068dba1 456 if not self.enabled:
940227cb 457 self.set_state("DOWN")
9068dba1
MT
458 return
459
460 now = datetime.datetime.utcnow()
461
462 time_delta = now - self.last_update
463 time_diff = time_delta.total_seconds()
940227cb 464
9068dba1
MT
465 time_down = self.settings.get_int("mirrors_time_down", 3*24*60*60)
466 if time_diff >= time_down:
940227cb 467 self.set_state("DOWN")
9068dba1 468 return
940227cb 469
9068dba1
MT
470 time_outofsync = self.settings.get_int("mirrors_time_outofsync", 6*60*60)
471 if time_diff >= time_outofsync:
472 self.set_state("OUTOFSYNC")
940227cb
MT
473 return
474
9068dba1
MT
475 self.set_state("UP")
476
477 def check_timestamp(self):
940227cb
MT
478 http = tornado.httpclient.AsyncHTTPClient()
479
480 http.fetch(self.url + ".timestamp",
54af860e 481 headers={ "Pragma" : "no-cache" },
940227cb
MT
482 callback=self.__check_timestamp_response)
483
484 def __check_timestamp_response(self, response):
485 if response.error:
486 logging.debug("Error getting timestamp from %s" % self.hostname)
a3ee39ce 487 self.set_state("DOWN")
940227cb
MT
488 return
489
490 try:
491 timestamp = int(response.body.strip())
492 except ValueError:
493 timestamp = 0
494
ea324f48 495 timestamp = datetime.datetime.utcfromtimestamp(timestamp)
9068dba1
MT
496
497 self.db.execute("UPDATE mirrors SET last_update = %s WHERE id = %s",
940227cb
MT
498 timestamp, self.id)
499
500 # Reload changed settings
bd17b7d1
MT
501 if hasattr(self, "_info"):
502 self._info["timestamp"] = timestamp
940227cb
MT
503
504 self.check_state()
505
506 logging.info("Successfully updated timestamp from %s" % self.hostname)
507
508 def check_filelist(self):
54af860e 509 # XXX need to remove data from disabled mirrors
9068dba1 510 if not self.enabled:
940227cb
MT
511 return
512
513 http = tornado.httpclient.AsyncHTTPClient()
514
515 http.fetch(self.url + ".filelist",
54af860e 516 headers={ "Pragma" : "no-cache" },
940227cb
MT
517 callback=self.__check_filelist_response)
518
519 def __check_filelist_response(self, response):
520 if response.error:
521 logging.debug("Error getting timestamp from %s" % self.hostname)
522 return
523
56b9c1d8 524 files = self.filelist
940227cb
MT
525
526 for file in response.body.splitlines():
56b9c1d8
MT
527 file = os.path.join(self.prefix, file)
528
529 if file in files:
530 files.remove(file)
531 continue
532
940227cb 533 self.db.execute("INSERT INTO mirror_files(mirror, filename) VALUES(%s, %s)",
56b9c1d8
MT
534 self.id, file)
535
536 for file in files:
537 self.db.execute("DELETE FROM mirror_files WHERE mirror=%s AND filename=%s",
538 self.id, file)
940227cb
MT
539
540 logging.info("Successfully updated mirror filelist from %s" % self.hostname)
541
54af860e
MT
542 @property
543 def prefer_for_countries(self):
0673d1b0
MT
544 countries = self._info.get("prefer_for_countries", "")
545 if countries:
546 return sorted(countries.split(", "))
54af860e 547
0673d1b0
MT
548 return []
549
550 @property
551 def prefer_for_countries_names(self):
9068dba1
MT
552 countries = [self.geoip.get_country_name(c.upper()) for c in self.prefer_for_countries]
553
554 return sorted(countries)
54af860e 555
119f55d7 556 def distance_to(self, location, ignore_preference=False):
0673d1b0 557 if not location:
9068dba1 558 return None
940227cb 559
9068dba1
MT
560 country_code = None
561 if location.country:
562 country_code = location.country.lower()
563
564 if not ignore_preference and country_code in self.prefer_for_countries:
0673d1b0
MT
565 return 0
566
9068dba1
MT
567 # http://www.movable-type.co.uk/scripts/latlong.html
568
569 if self.latitude is None:
570 return None
571
572 if self.longitude is None:
573 return None
574
575 earth = 6371 # km
576 delta_lat = math.radians(self.latitude - location.latitude)
577 delta_lon = math.radians(self.longitude - location.longitude)
578
579 lat1 = math.radians(self.latitude)
580 lat2 = math.radians(location.latitude)
581
582 a = math.sin(delta_lat / 2) ** 2
583 a += math.cos(lat1) * math.cos(lat2) * (math.sin(delta_lon / 2) ** 2)
0673d1b0 584
9068dba1
MT
585 b1 = math.sqrt(a)
586 b2 = math.sqrt(1 - a)
0673d1b0 587
9068dba1
MT
588 c = 2 * math.atan2(b1, b2)
589
590 return c * earth
0673d1b0
MT
591
592 def traffic(self, since):
593 # XXX needs to be done better
594
595 files = {}
596 for entry in self.db.query("SELECT filename, filesize FROM files"):
597 files[entry.filename] = entry.filesize
598
599 query = "SELECT COUNT(filename) as count, filename FROM log_download WHERE mirror = %s"
600 query += " AND date >= %s GROUP BY filename"
601
602 traffic = 0
603 for entry in self.db.query(query, self.id, since):
604 if files.has_key(entry.filename):
605 traffic += entry.count * files[entry.filename]
606
607 return traffic
608
609 @property
610 def priority(self):
f1f7eb7e 611 return self._info.get("priority", 10)
940227cb 612
bd17b7d1
MT
613 @property
614 def development(self):
ea324f48 615 return self._info.get("mirrorlist_devel", False)
bd17b7d1
MT
616
617 @property
618 def mirrorlist(self):
9068dba1
MT
619 return self._info.get("mirrorlist", False)
620
621 @property
622 def addresses(self):
623 if not hasattr(self, "__addresses"):
b898caea
MT
624 try:
625 addrinfo = socket.getaddrinfo(self.hostname, 0, socket.AF_UNSPEC, socket.SOCK_STREAM)
626 except:
627 raise Exception("Could not resolve %s" % self.hostname)
9068dba1
MT
628
629 ret = []
630 for family, socktype, proto, canonname, address in addrinfo:
631 if family == socket.AF_INET:
632 address, port = address
633 elif family == socket.AF_INET6:
634 address, port, flowid, scopeid = address
635 ret.append((family, address))
636
637 self.__addresses = ret
638
639 return self.__addresses
640
641 @property
642 def addresses6(self):
643 return [address for family, address in self.addresses if family == socket.AF_INET6]
644
645 @property
646 def addresses4(self):
647 return [address for family, address in self.addresses if family == socket.AF_INET]