Fix a couple more bugs
[invirt/packages/invirt-web.git] / code / main.py
1 #!/usr/bin/python
2 """Main CGI script for web interface"""
3
4 import base64
5 import cPickle
6 import cgi
7 import datetime
8 import hmac
9 import os
10 import sha
11 import simplejson
12 import sys
13 import time
14 import urllib
15 from StringIO import StringIO
16
17 def revertStandardError():
18     """Move stderr to stdout, and return the contents of the old stderr."""
19     errio = sys.stderr
20     if not isinstance(errio, StringIO):
21         return None
22     sys.stderr = sys.stdout
23     errio.seek(0)
24     return errio.read()
25
26 def printError():
27     """Revert stderr to stdout, and print the contents of stderr"""
28     if isinstance(sys.stderr, StringIO):
29         print revertStandardError()
30
31 if __name__ == '__main__':
32     import atexit
33     atexit.register(printError)
34     sys.stderr = StringIO()
35
36 sys.path.append('/home/ecprice/.local/lib/python2.5/site-packages')
37
38 import templates
39 from Cheetah.Template import Template
40 import sipb_xen_database
41 from sipb_xen_database import Machine, CDROM, ctx, connect, MachineAccess, Type, Autoinstall
42 import validation
43 import cache_acls
44 from webcommon import InvalidInput, CodeError, state
45 import controls
46
47 class Checkpoint:
48     def __init__(self):
49         self.start_time = time.time()
50         self.checkpoints = []
51
52     def checkpoint(self, s):
53         self.checkpoints.append((s, time.time()))
54
55     def __str__(self):
56         return ('Timing info:\n%s\n' %
57                 '\n'.join(['%s: %s' % (d, t - self.start_time) for
58                            (d, t) in self.checkpoints]))
59
60 checkpoint = Checkpoint()
61
62 def jquote(string):
63     return "'" + string.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n') + "'"
64
65 def helppopup(subj):
66     """Return HTML code for a (?) link to a specified help topic"""
67     return ('<span class="helplink"><a href="help?' +
68             cgi.escape(urllib.urlencode(dict(subject=subj, simple='true')))
69             +'" target="_blank" ' +
70             'onclick="return helppopup(' + cgi.escape(jquote(subj)) + ')">(?)</a></span>')
71
72 def makeErrorPre(old, addition):
73     if addition is None:
74         return
75     if old:
76         return old[:-6]  + '\n----\n' + str(addition) + '</pre>'
77     else:
78         return '<p>STDERR:</p><pre>' + str(addition) + '</pre>'
79
80 Template.sipb_xen_database = sipb_xen_database
81 Template.helppopup = staticmethod(helppopup)
82 Template.err = None
83
84 class JsonDict:
85     """Class to store a dictionary that will be converted to JSON"""
86     def __init__(self, **kws):
87         self.data = kws
88         if 'err' in kws:
89             err = kws['err']
90             del kws['err']
91             self.addError(err)
92
93     def __str__(self):
94         return simplejson.dumps(self.data)
95
96     def addError(self, text):
97         """Add stderr text to be displayed on the website."""
98         self.data['err'] = \
99             makeErrorPre(self.data.get('err'), text)
100
101 class Defaults:
102     """Class to store default values for fields."""
103     memory = 256
104     disk = 4.0
105     cdrom = ''
106     autoinstall = ''
107     name = ''
108     type = 'linux-hvm'
109
110     def __init__(self, max_memory=None, max_disk=None, **kws):
111         if max_memory is not None:
112             self.memory = min(self.memory, max_memory)
113         if max_disk is not None:
114             self.max_disk = min(self.disk, max_disk)
115         for key in kws:
116             setattr(self, key, kws[key])
117
118
119
120 DEFAULT_HEADERS = {'Content-Type': 'text/html'}
121
122 def error(op, username, fields, err, emsg):
123     """Print an error page when a CodeError occurs"""
124     d = dict(op=op, user=username, errorMessage=str(err),
125              stderr=emsg)
126     return templates.error(searchList=[d])
127
128 def invalidInput(op, username, fields, err, emsg):
129     """Print an error page when an InvalidInput exception occurs"""
130     d = dict(op=op, user=username, err_field=err.err_field,
131              err_value=str(err.err_value), stderr=emsg,
132              errorMessage=str(err))
133     return templates.invalid(searchList=[d])
134
135 def hasVnc(status):
136     """Does the machine with a given status list support VNC?"""
137     if status is None:
138         return False
139     for l in status:
140         if l[0] == 'device' and l[1][0] == 'vfb':
141             d = dict(l[1][1:])
142             return 'location' in d
143     return False
144
145 def parseCreate(username, state, fields):
146     kws = dict([(kw, fields.getfirst(kw)) for kw in 'name owner memory disksize vmtype cdrom clone_from'.split()])
147     validate = validation.Validate(username, state, **kws)
148     return dict(contact=username, name=validate.name, memory=validate.memory,
149                 disksize=validate.disksize, owner=validate.owner, machine_type=validate.vmtype,
150                 cdrom=getattr(validate, 'cdrom', None),
151                 clone_from=getattr(validate, 'clone_from', None))
152
153 def create(username, state, fields):
154     """Handler for create requests."""
155     try:
156         parsed_fields = parseCreate(username, state, fields)
157         machine = controls.createVm(username, **parsed_fields)
158     except InvalidInput, err:
159         pass
160     else:
161         err = None
162     state.clear() #Changed global state
163     d = getListDict(username, state)
164     d['err'] = err
165     if err:
166         for field in fields.keys():
167             setattr(d['defaults'], field, fields.getfirst(field))
168     else:
169         d['new_machine'] = parsed_fields['name']
170     return templates.list(searchList=[d])
171
172
173 def getListDict(username, state):
174     """Gets the list of local variables used by list.tmpl."""
175     checkpoint.checkpoint('Starting')
176     machines = state.machines
177     checkpoint.checkpoint('Got my machines')
178     on = {}
179     has_vnc = {}
180     xmlist = state.xmlist
181     checkpoint.checkpoint('Got uptimes')
182     can_clone = 'ice3' not in state.xmlist_raw
183     for m in machines:
184         if m not in xmlist:
185             has_vnc[m] = 'Off'
186             m.uptime = None
187         else:
188             m.uptime = xmlist[m]['uptime']
189             if xmlist[m]['console']:
190                 has_vnc[m] = True
191             elif m.type.hvm:
192                 has_vnc[m] = "WTF?"
193             else:
194                 has_vnc[m] = "ParaVM"+helppopup("ParaVM Console")
195     max_memory = validation.maxMemory(username, state)
196     max_disk = validation.maxDisk(username)
197     checkpoint.checkpoint('Got max mem/disk')
198     defaults = Defaults(max_memory=max_memory,
199                         max_disk=max_disk,
200                         owner=username,
201                         cdrom='gutsy-i386')
202     checkpoint.checkpoint('Got defaults')
203     def sortkey(machine):
204         return (machine.owner != username, machine.owner, machine.name)
205     machines = sorted(machines, key=sortkey)
206     d = dict(user=username,
207              cant_add_vm=validation.cantAddVm(username, state),
208              max_memory=max_memory,
209              max_disk=max_disk,
210              defaults=defaults,
211              machines=machines,
212              has_vnc=has_vnc,
213              can_clone=can_clone)
214     return d
215
216 def listVms(username, state, fields):
217     """Handler for list requests."""
218     checkpoint.checkpoint('Getting list dict')
219     d = getListDict(username, state)
220     checkpoint.checkpoint('Got list dict')
221     return templates.list(searchList=[d])
222
223 def vnc(username, state, fields):
224     """VNC applet page.
225
226     Note that due to same-domain restrictions, the applet connects to
227     the webserver, which needs to forward those requests to the xen
228     server.  The Xen server runs another proxy that (1) authenticates
229     and (2) finds the correct port for the VM.
230
231     You might want iptables like:
232
233     -t nat -A PREROUTING -s ! 18.181.0.60 -i eth1 -p tcp -m tcp \
234       --dport 10003 -j DNAT --to-destination 18.181.0.60:10003
235     -t nat -A POSTROUTING -d 18.181.0.60 -o eth1 -p tcp -m tcp \
236       --dport 10003 -j SNAT --to-source 18.187.7.142
237     -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp \
238       --dport 10003 -j ACCEPT
239
240     Remember to enable iptables!
241     echo 1 > /proc/sys/net/ipv4/ip_forward
242     """
243     machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
244
245     TOKEN_KEY = "0M6W0U1IXexThi5idy8mnkqPKEq1LtEnlK/pZSn0cDrN"
246
247     data = {}
248     data["user"] = username
249     data["machine"] = machine.name
250     data["expires"] = time.time()+(5*60)
251     pickled_data = cPickle.dumps(data)
252     m = hmac.new(TOKEN_KEY, digestmod=sha)
253     m.update(pickled_data)
254     token = {'data': pickled_data, 'digest': m.digest()}
255     token = cPickle.dumps(token)
256     token = base64.urlsafe_b64encode(token)
257
258     status = controls.statusInfo(machine)
259     has_vnc = hasVnc(status)
260
261     d = dict(user=username,
262              on=status,
263              has_vnc=has_vnc,
264              machine=machine,
265              hostname=os.environ.get('SERVER_NAME', 'localhost'),
266              authtoken=token)
267     return templates.vnc(searchList=[d])
268
269 def getHostname(nic):
270     """Find the hostname associated with a NIC.
271
272     XXX this should be merged with the similar logic in DNS and DHCP.
273     """
274     if nic.hostname and '.' in nic.hostname:
275         return nic.hostname
276     elif nic.machine:
277         return nic.machine.name + '.xvm.mit.edu'
278     else:
279         return None
280
281
282 def getNicInfo(data_dict, machine):
283     """Helper function for info, get data on nics for a machine.
284
285     Modifies data_dict to include the relevant data, and returns a list
286     of (key, name) pairs to display "name: data_dict[key]" to the user.
287     """
288     data_dict['num_nics'] = len(machine.nics)
289     nic_fields_template = [('nic%s_hostname', 'NIC %s Hostname'),
290                            ('nic%s_mac', 'NIC %s MAC Addr'),
291                            ('nic%s_ip', 'NIC %s IP'),
292                            ]
293     nic_fields = []
294     for i in range(len(machine.nics)):
295         nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template])
296         if not i:
297             data_dict['nic%s_hostname' % i] = getHostname(machine.nics[i])
298         data_dict['nic%s_mac' % i] = machine.nics[i].mac_addr
299         data_dict['nic%s_ip' % i] = machine.nics[i].ip
300     if len(machine.nics) == 1:
301         nic_fields = [(x, y.replace('NIC 0 ', '')) for x, y in nic_fields]
302     return nic_fields
303
304 def getDiskInfo(data_dict, machine):
305     """Helper function for info, get data on disks for a machine.
306
307     Modifies data_dict to include the relevant data, and returns a list
308     of (key, name) pairs to display "name: data_dict[key]" to the user.
309     """
310     data_dict['num_disks'] = len(machine.disks)
311     disk_fields_template = [('%s_size', '%s size')]
312     disk_fields = []
313     for disk in machine.disks:
314         name = disk.guest_device_name
315         disk_fields.extend([(x % name, y % name) for x, y in
316                             disk_fields_template])
317         data_dict['%s_size' % name] = "%0.1f GiB" % (disk.size / 1024.)
318     return disk_fields
319
320 def command(username, state, fields):
321     """Handler for running commands like boot and delete on a VM."""
322     back = fields.getfirst('back')
323     try:
324         d = controls.commandResult(username, state, fields)
325         if d['command'] == 'Delete VM':
326             back = 'list'
327     except InvalidInput, err:
328         if not back:
329             raise
330         print >> sys.stderr, err
331         result = err
332     else:
333         result = 'Success!'
334         if not back:
335             return templates.command(searchList=[d])
336     if back == 'list':
337         state.clear() #Changed global state
338         d = getListDict(username, state)
339         d['result'] = result
340         return templates.list(searchList=[d])
341     elif back == 'info':
342         machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
343         return ({'Status': '302',
344                  'Location': '/info?machine_id=%d' % machine.machine_id},
345                 "You shouldn't see this message.")
346     else:
347         raise InvalidInput('back', back, 'Not a known back page.')
348
349 def modifyDict(username, state, fields):
350     """Modify a machine as specified by CGI arguments.
351
352     Return a list of local variables for modify.tmpl.
353     """
354     olddisk = {}
355     transaction = ctx.current.create_transaction()
356     try:
357         kws = dict([(kw, fields.getfirst(kw)) for kw in 'machine_id owner admin contact name memory vmtype disksize'.split()])
358         validate = validation.Validate(username, state, **kws)
359         machine = validate.machine
360         oldname = machine.name
361
362         if hasattr(validate, 'memory'):
363             machine.memory = validate.memory
364
365         if hasattr(validate, 'vmtype'):
366             machine.type = validate.vmtype
367
368         if hasattr(validate, 'disksize'):
369             disksize = validate.disksize
370             disk = machine.disks[0]
371             if disk.size != disksize:
372                 olddisk[disk.guest_device_name] = disksize
373                 disk.size = disksize
374                 ctx.current.save(disk)
375
376         update_acl = False
377         if hasattr(validate, 'owner') and validate.owner != machine.owner:
378             machine.owner = validate.owner
379             update_acl = True
380         if hasattr(validate, 'name'):
381             machine.name = name
382         if hasattr(validate, 'admin') and validate.admin != machine.administrator:
383             machine.administrator = validate.admin
384             update_acl = True
385         if hasattr(validate, 'contact'):
386             machine.contact = validate.contact
387
388         ctx.current.save(machine)
389         if update_acl:
390             print >> sys.stderr, machine, machine.administrator
391             cache_acls.refreshMachine(machine)
392         transaction.commit()
393     except:
394         transaction.rollback()
395         raise
396     for diskname in olddisk:
397         controls.resizeDisk(oldname, diskname, str(olddisk[diskname]))
398     if hasattr(validate, 'name'):
399         controls.renameMachine(machine, oldname, validate.name)
400     return dict(user=username,
401                 command="modify",
402                 machine=machine)
403
404 def modify(username, state, fields):
405     """Handler for modifying attributes of a machine."""
406     try:
407         modify_dict = modifyDict(username, state, fields)
408     except InvalidInput, err:
409         result = None
410         machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
411     else:
412         machine = modify_dict['machine']
413         result = 'Success!'
414         err = None
415     info_dict = infoDict(username, machine)
416     info_dict['err'] = err
417     if err:
418         for field in fields.keys():
419             setattr(info_dict['defaults'], field, fields.getfirst(field))
420     info_dict['result'] = result
421     return templates.info(searchList=[info_dict])
422
423
424 def helpHandler(username, state, fields):
425     """Handler for help messages."""
426     simple = fields.getfirst('simple')
427     subjects = fields.getlist('subject')
428
429     help_mapping = {'ParaVM Console': """
430 ParaVM machines do not support local console access over VNC.  To
431 access the serial console of these machines, you can SSH with Kerberos
432 to console.xvm.mit.edu, using the name of the machine as your
433 username.""",
434                     'HVM/ParaVM': """
435 HVM machines use the virtualization features of the processor, while
436 ParaVM machines use Xen's emulation of virtualization features.  You
437 want an HVM virtualized machine.""",
438                     'CPU Weight': """
439 Don't ask us!  We're as mystified as you are.""",
440                     'Owner': """
441 The owner field is used to determine <a
442 href="help?subject=Quotas">quotas</a>.  It must be the name of a
443 locker that you are an AFS administrator of.  In particular, you or an
444 AFS group you are a member of must have AFS rlidwka bits on the
445 locker.  You can check who administers the LOCKER locker using the
446 commands 'attach LOCKER; fs la /mit/LOCKER' on Athena.)  See also <a
447 href="help?subject=Administrator">administrator</a>.""",
448                     'Administrator': """
449 The administrator field determines who can access the console and
450 power on and off the machine.  This can be either a user or a moira
451 group.""",
452                     'Quotas': """
453 Quotas are determined on a per-locker basis.  Each locker may have a
454 maximum of 512 megabytes of active ram, 50 gigabytes of disk, and 4
455 active machines.""",
456                     'Console': """
457 <strong>Framebuffer:</strong> At a Linux boot prompt in your VM, try
458 setting <tt>fb=false</tt> to disable the framebuffer.  If you don't,
459 your machine will run just fine, but the applet's display of the
460 console will suffer artifacts.
461 """
462                     }
463
464     if not subjects:
465         subjects = sorted(help_mapping.keys())
466
467     d = dict(user=username,
468              simple=simple,
469              subjects=subjects,
470              mapping=help_mapping)
471
472     return templates.help(searchList=[d])
473
474
475 def badOperation(u, e):
476     """Function called when accessing an unknown URI."""
477     raise CodeError("Unknown operation")
478
479 def infoDict(username, machine):
480     """Get the variables used by info.tmpl."""
481     status = controls.statusInfo(machine)
482     checkpoint.checkpoint('Getting status info')
483     has_vnc = hasVnc(status)
484     if status is None:
485         main_status = dict(name=machine.name,
486                            memory=str(machine.memory))
487         uptime = None
488         cputime = None
489     else:
490         main_status = dict(status[1:])
491         start_time = float(main_status.get('start_time', 0))
492         uptime = datetime.timedelta(seconds=int(time.time()-start_time))
493         cpu_time_float = float(main_status.get('cpu_time', 0))
494         cputime = datetime.timedelta(seconds=int(cpu_time_float))
495     checkpoint.checkpoint('Status')
496     display_fields = """name uptime memory state cpu_weight on_reboot 
497      on_poweroff on_crash on_xend_start on_xend_stop bootloader""".split()
498     display_fields = [('name', 'Name'),
499                       ('owner', 'Owner'),
500                       ('administrator', 'Administrator'),
501                       ('contact', 'Contact'),
502                       ('type', 'Type'),
503                       'NIC_INFO',
504                       ('uptime', 'uptime'),
505                       ('cputime', 'CPU usage'),
506                       ('memory', 'RAM'),
507                       'DISK_INFO',
508                       ('state', 'state (xen format)'),
509                       ('cpu_weight', 'CPU weight'+helppopup('CPU Weight')),
510                       ('on_reboot', 'Action on VM reboot'),
511                       ('on_poweroff', 'Action on VM poweroff'),
512                       ('on_crash', 'Action on VM crash'),
513                       ('on_xend_start', 'Action on Xen start'),
514                       ('on_xend_stop', 'Action on Xen stop'),
515                       ('bootloader', 'Bootloader options'),
516                       ]
517     fields = []
518     machine_info = {}
519     machine_info['name'] = machine.name
520     machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
521     machine_info['owner'] = machine.owner
522     machine_info['administrator'] = machine.administrator
523     machine_info['contact'] = machine.contact
524
525     nic_fields = getNicInfo(machine_info, machine)
526     nic_point = display_fields.index('NIC_INFO')
527     display_fields = (display_fields[:nic_point] + nic_fields +
528                       display_fields[nic_point+1:])
529
530     disk_fields = getDiskInfo(machine_info, machine)
531     disk_point = display_fields.index('DISK_INFO')
532     display_fields = (display_fields[:disk_point] + disk_fields +
533                       display_fields[disk_point+1:])
534
535     main_status['memory'] += ' MiB'
536     for field, disp in display_fields:
537         if field in ('uptime', 'cputime') and locals()[field] is not None:
538             fields.append((disp, locals()[field]))
539         elif field in machine_info:
540             fields.append((disp, machine_info[field]))
541         elif field in main_status:
542             fields.append((disp, main_status[field]))
543         else:
544             pass
545             #fields.append((disp, None))
546
547     checkpoint.checkpoint('Got fields')
548
549
550     max_mem = validation.maxMemory(machine.owner, state, machine, False)
551     checkpoint.checkpoint('Got mem')
552     max_disk = validation.maxDisk(machine.owner, machine)
553     defaults = Defaults()
554     for name in 'machine_id name administrator owner memory contact'.split():
555         setattr(defaults, name, getattr(machine, name))
556     defaults.type = machine.type.type_id
557     defaults.disk = "%0.2f" % (machine.disks[0].size/1024.)
558     checkpoint.checkpoint('Got defaults')
559     d = dict(user=username,
560              on=status is not None,
561              machine=machine,
562              defaults=defaults,
563              has_vnc=has_vnc,
564              uptime=str(uptime),
565              ram=machine.memory,
566              max_mem=max_mem,
567              max_disk=max_disk,
568              owner_help=helppopup("Owner"),
569              fields = fields)
570     return d
571
572 def info(username, state, fields):
573     """Handler for info on a single VM."""
574     machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
575     d = infoDict(username, machine)
576     checkpoint.checkpoint('Got infodict')
577     return templates.info(searchList=[d])
578
579 def unauthFront(_, _2, fields):
580     """Information for unauth'd users."""
581     return templates.unauth(searchList=[{'simple' : True}])
582
583 mapping = dict(list=listVms,
584                vnc=vnc,
585                command=command,
586                modify=modify,
587                info=info,
588                create=create,
589                help=helpHandler,
590                unauth=unauthFront)
591
592 def printHeaders(headers):
593     """Print a dictionary as HTTP headers."""
594     for key, value in headers.iteritems():
595         print '%s: %s' % (key, value)
596     print
597
598
599 def getUser(environ):
600     """Return the current user based on the SSL environment variables"""
601     email = environ.get('SSL_CLIENT_S_DN_Email', None)
602     if email is None:
603         return None
604     if not email.endswith('@MIT.EDU'):
605         return None
606     return email[:-8]
607
608 def main(operation, username, state, fields):
609     start_time = time.time()
610     fun = mapping.get(operation, badOperation)
611
612     if fun not in (helpHandler, ):
613         connect('postgres://sipb-xen@sipb-xen-dev.mit.edu/sipb_xen')
614     try:
615         checkpoint.checkpoint('Before')
616         output = fun(username, state, fields)
617         checkpoint.checkpoint('After')
618
619         headers = dict(DEFAULT_HEADERS)
620         if isinstance(output, tuple):
621             new_headers, output = output
622             headers.update(new_headers)
623         e = revertStandardError()
624         if e:
625             if isinstance(output, basestring):
626                 sys.stderr = StringIO()
627                 x = str(output)
628                 print >> sys.stderr, x
629                 print >> sys.stderr, 'XXX'
630                 print >> sys.stderr, e
631                 raise Exception()
632             output.addError(e)
633         printHeaders(headers)
634         output_string =  str(output)
635         checkpoint.checkpoint('output as a string')
636         print output_string
637         if fields.has_key('timedebug'):
638             print '<pre>%s</pre>' % cgi.escape(checkpoint)
639     except Exception, err:
640         if not fields.has_key('js'):
641             if isinstance(err, CodeError):
642                 print 'Content-Type: text/html\n'
643                 e = revertStandardError()
644                 print error(operation, state.username, fields, err, e)
645                 sys.exit(1)
646             if isinstance(err, InvalidInput):
647                 print 'Content-Type: text/html\n'
648                 e = revertStandardError()
649                 print invalidInput(operation, state.username, fields, err, e)
650                 sys.exit(1)
651         print 'Content-Type: text/plain\n'
652         print 'Uh-oh!  We experienced an error.'
653         print 'Please email xvm-dev@mit.edu with the contents of this page.'
654         print '----'
655         e = revertStandardError()
656         print e
657         print '----'
658         raise
659
660 if __name__ == '__main__':
661     fields = cgi.FieldStorage()
662
663     if fields.has_key('sqldebug'):
664         import logging
665         logging.basicConfig()
666         logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
667         logging.getLogger('sqlalchemy.orm.unitofwork').setLevel(logging.INFO)
668
669     username = getUser(os.environ)
670     state.username = username
671     operation = os.environ.get('PATH_INFO', '')
672     if not operation:
673         print "Status: 301 Moved Permanently"
674         print 'Location: ' + os.environ['SCRIPT_NAME']+'/\n'
675         sys.exit(0)
676     if username is None:
677         operation = 'unauth'
678     if operation.startswith('/'):
679         operation = operation[1:]
680     if not operation:
681         operation = 'list'
682
683     if os.getenv("SIPB_XEN_PROFILE"):
684         import profile
685         profile.run('main(operation, username, state, fields)', 'log-'+operation)
686     else:
687         main(operation, username, state, fields)