"Cherry-pick" r2557 (Re-arrange the authz configuration.) to the
[invirt/packages/invirt-web.git] / code / validation.py
1 #!/usr/bin/python
2
3 import cache_acls
4 import getafsgroups
5 import re
6 import string
7 import dns.resolver
8 from invirt.database import Machine, NIC, Type, Disk, CDROM, Autoinstall, Owner
9 from invirt.config import structs as config
10 from invirt.common import InvalidInput, CodeError
11
12 MIN_MEMORY_SINGLE = 16
13 MIN_DISK_SINGLE = 0.1
14
15 class Validate:
16     def __init__(self, username, state, machine_id=None, name=None, description=None, owner=None,
17                  admin=None, contact=None, memory=None, disksize=None,
18                  vmtype=None, cdrom=None, autoinstall=None, strict=False):
19         # XXX Successive quota checks aren't a good idea, since you
20         # can't necessarily change the locker and disk size at the
21         # same time.
22         created_new = (machine_id is None)
23
24         if strict:
25             if name is None:
26                 raise InvalidInput('name', name, "You must provide a machine name.")
27             if description is None:
28                 raise InvalidInput('description', description, "You must provide a description.")
29             if memory is None:
30                 raise InvalidInput('memory', memory, "You must provide a memory size.")
31             if disksize is None:
32                 raise InvalidInput('disk', disksize, "You must provide a disk size.")
33
34         if machine_id is not None:
35             self.machine = testMachineId(username, state, machine_id)
36         machine = getattr(self, 'machine', None)
37
38         owner = testOwner(username, owner, machine)
39         if owner is not None:
40             self.owner = owner
41         self.admin = testAdmin(username, admin, machine)
42         contact = testContact(username, contact, machine)
43         if contact is not None:
44             self.contact = contact
45         name = testName(username, name, machine)
46         if name is not None:
47             self.name = name
48         description = testDescription(username, description, machine)
49         if description is not None:
50             self.description = description
51         if memory is not None:
52             self.memory = validMemory(self.owner, state, memory, machine,
53                                       on=not created_new)
54         if disksize is not None:
55             self.disksize = validDisk(self.owner, state, disksize, machine)
56         if vmtype is not None:
57             self.vmtype = validVmType(vmtype)
58         if cdrom is not None:
59             if not CDROM.query().get(cdrom):
60                 raise CodeError("Invalid cdrom type '%s'" % cdrom)
61             self.cdrom = cdrom
62         if autoinstall is not None:
63             #raise InvalidInput('autoinstall', 'install',
64             #                   "The autoinstaller has been temporarily disabled")
65             self.autoinstall = Autoinstall.query().get(autoinstall)
66
67
68 def getMachinesByOwner(owner, machine=None):
69     """Return the machines owned by the same as a machine.
70
71     If the machine is None, return the machines owned by the same
72     user.
73     """
74     if machine:
75         owner = machine.owner
76     return Machine.query().filter_by(owner=owner)
77
78 def maxMemory(owner, g, machine=None, on=True):
79     """Return the maximum memory for a machine or a user.
80
81     If machine is None, return the memory available for a new
82     machine.  Else, return the maximum that machine can have.
83
84     on is whether the machine should be turned on.  If false, the max
85     memory for the machine to change to, if it is left off, is
86     returned.
87     """
88     (quota_total, quota_single) = Owner.getMemoryQuotas(machine.owner if machine else owner)
89
90     if not on:
91         return quota_single
92     machines = getMachinesByOwner(owner, machine)
93     active_machines = [m for m in machines if m.name in g.xmlist_raw]
94     mem_usage = sum([x.memory for x in active_machines if x != machine])
95     return min(quota_single, quota_total-mem_usage)
96
97 def maxDisk(owner, machine=None):
98     """Return the maximum disk that a machine can reach.
99
100     If machine is None, the maximum disk for a new machine. Otherwise,
101     return the maximum that a given machine can be changed to.
102     """
103     (quota_total, quota_single) = Owner.getDiskQuotas(machine.owner if machine else owner)
104
105     if machine is not None:
106         machine_id = machine.machine_id
107     else:
108         machine_id = None
109     disk_usage = Disk.query().filter(Disk.c.machine_id != machine_id).\
110                      join('machine').\
111                      filter_by(owner=owner).sum(Disk.c.size) or 0
112     return min(quota_single, quota_total-disk_usage/1024.)
113
114 def cantAddVm(owner, g):
115     machines = getMachinesByOwner(owner)
116     active_machines = [m for m in machines if m.name in g.xmlist_raw]
117     (quota_total, quota_active) = Owner.getVMQuotas(owner)
118     if machines.count() >= quota_total:
119         return 'You have too many VMs to create a new one.'
120     if len(active_machines) >= quota_active:
121         return ('You already have the maximum number of VMs turned on.  '
122                 'To create more, turn one off.')
123     return False
124
125 def haveAccess(user, state, machine):
126     """Return whether a user has administrative access to a machine"""
127     return (user in cache_acls.accessList(machine)
128             or (machine.adminable and state.isadmin))
129
130 def owns(user, machine):
131     """Return whether a user owns a machine"""
132     return user in expandLocker(machine.owner)
133
134 def validMachineName(name):
135     """Check that name is valid for a machine name"""
136     if not name:
137         return False
138     charset = string.lowercase + string.digits + '-'
139     if '-' in (name[0], name[-1]) or len(name) > 63:
140         return False
141     for x in name:
142         if x not in charset:
143             return False
144     return True
145
146 def validMemory(owner, g, memory, machine=None, on=True):
147     """Parse and validate limits for memory for a given owner and machine.
148
149     on is whether the memory must be valid after the machine is
150     switched on.
151     """
152     try:
153         memory = int(memory)
154         if memory < MIN_MEMORY_SINGLE:
155             raise ValueError
156     except ValueError:
157         raise InvalidInput('memory', memory,
158                            "Minimum %s MiB" % MIN_MEMORY_SINGLE)
159     max_val = maxMemory(owner, g, machine, on)
160     if not g.isadmin and memory > max_val:
161         raise InvalidInput('memory', memory,
162                            'Maximum %s MiB for %s' % (max_val, owner))
163     return memory
164
165 def validDisk(owner, g, disk, machine=None):
166     """Parse and validate limits for disk for a given owner and machine."""
167     try:
168         disk = float(disk)
169         if not g.isadmin and disk > maxDisk(owner, machine):
170             raise InvalidInput('disk', disk,
171                                "Maximum %s G" % maxDisk(owner, machine))
172         disk = int(disk * 1024)
173         if disk < MIN_DISK_SINGLE * 1024:
174             raise ValueError
175     except ValueError:
176         raise InvalidInput('disk', disk,
177                            "Minimum %s GiB" % MIN_DISK_SINGLE)
178     return disk
179
180 def validVmType(vm_type):
181     if vm_type is None:
182         return None
183     t = Type.query().get(vm_type)
184     if t is None:
185         raise CodeError("Invalid vm type '%s'"  % vm_type)
186     return t
187
188 def testMachineId(user, state, machine_id, exists=True):
189     """Parse, validate and check authorization for a given user and machine.
190
191     If exists is False, don't check that it exists.
192     """
193     if machine_id is None:
194         raise InvalidInput('machine_id', machine_id,
195                            "Must specify a machine ID.")
196     try:
197         machine_id = int(machine_id)
198     except ValueError:
199         raise InvalidInput('machine_id', machine_id, "Must be an integer.")
200     machine = Machine.query().get(machine_id)
201     if exists and machine is None:
202         raise InvalidInput('machine_id', machine_id, "Does not exist.")
203     if machine is not None and not haveAccess(user, state, machine):
204         raise InvalidInput('machine_id', machine_id,
205                            "You do not have access to this machine.")
206     return machine
207
208 def testAdmin(user, admin, machine):
209     """Determine whether a user can set the admin of a machine to this value.
210
211     Return the value to set the admin field to (possibly 'system:' +
212     admin).  XXX is modifying this a good idea?
213     """
214     if admin is None:
215         return None
216     if machine is not None and admin == machine.administrator:
217         return admin
218     if admin == user:
219         return admin
220     if ':' not in admin:
221         if cache_acls.isUser(admin):
222             return admin
223         admin = 'system:' + admin
224     try:
225         if user in getafsgroups.getAfsGroupMembers(admin, config.authz.cells[0].cell):
226             return admin
227     except getafsgroups.AfsProcessError, e:
228         errmsg = str(e)
229         if errmsg.startswith("pts: User or group doesn't exist"):
230             errmsg = 'The group "%s" does not exist.' % admin
231         raise InvalidInput('administrator', admin, errmsg)
232     #XXX Should we require that user is in the admin group?
233     return admin
234
235 def testOwner(user, owner, machine=None):
236     """Determine whether a user can set the owner of a machine to this value.
237
238     If machine is None, this is the owner of a new machine.
239     """
240     if machine is not None and owner in (machine.owner, None):
241         return machine.owner
242     if owner is None:
243         raise InvalidInput('owner', owner, "Owner must be specified")
244     if '@' in owner:
245         raise InvalidInput('owner', owner, "No cross-realm Hesiod lockers allowed")
246     try:
247         if user not in cache_acls.expandLocker(owner):
248             raise InvalidInput('owner', owner, 'You do not have access to the '
249                                + owner + ' locker')
250     except getafsgroups.AfsProcessError, e:
251         raise InvalidInput('owner', owner, str(e))
252     return owner
253
254 def testContact(user, contact, machine=None):
255     if contact is None or (machine is not None and contact == machine.contact):
256         return None
257     if not re.match("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$", contact, re.I):
258         raise InvalidInput('contact', contact, "Not a valid email.")
259     return contact
260
261 def testName(user, name, machine=None):
262     if name is None:
263         return None
264     name = name.lower()
265     if machine is not None and name == machine.name:
266         return None
267     try:
268         hostname = '%s.%s.' % (name, config.dns.domains[0])
269         resolver = dns.resolver.Resolver()
270         resolver.nameservers = ['127.0.0.1']
271         try:
272             resolver.query(hostname, 'A')
273         except dns.resolver.NoAnswer, e:
274             # If we can get the TXT record, then we can verify it's
275             # reserved. If this lookup fails, let it bubble up and be
276             # dealt with
277             answer = resolver.query(hostname, 'TXT')
278             txt = answer[0].strings[0]
279             if txt.startswith('reserved'):
280                 raise InvalidInput('name', name, 'The name you have requested has been %s. For more information, contact us at %s' % (txt, config.dns.contact))
281
282         # If the hostname didn't exist, it would have thrown an
283         # exception by now - error out
284         raise InvalidInput('name', name, 'Name is already taken.')
285     except dns.resolver.NXDOMAIN, e:
286         if not validMachineName(name):
287             raise InvalidInput('name', name, 'You must provide a machine name.  Max 63 chars, alnum plus \'-\', does not begin or end with \'-\'.')
288         return name
289     except InvalidInput:
290         raise
291     except:
292         # Any other error is a validation failure
293         raise InvalidInput('name', name, 'We were unable to verify that this name is available. If you believe this is in error, please contact us at %s' % config.dns.contact)
294
295 def testDescription(user, description, machine=None):
296     if description is None or description.strip() == '':
297         return None
298     return description.strip()
299
300 def testHostname(user, hostname, machine):
301     for nic in machine.nics:
302         if hostname == nic.hostname:
303             return hostname
304     # check if doesn't already exist
305     if NIC.select_by(hostname=hostname):
306         raise InvalidInput('hostname', hostname,
307                            "Already exists")
308     if not re.match("^[A-Z0-9-]{1,22}$", hostname, re.I):
309         raise InvalidInput('hostname', hostname, "Not a valid hostname; "
310                            "must only use number, letters, and dashes.")
311     return hostname