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