source: trunk/packages/sipb-xen-www/code/main.py @ 572

Last change on this file since 572 was 572, checked in by ecprice, 16 years ago

Put validation behind more abstraction.

  • Property svn:executable set to *
File size: 24.0 KB
Line 
1#!/usr/bin/python
2"""Main CGI script for web interface"""
3
4import base64
5import cPickle
6import cgi
7import datetime
8import hmac
9import os
10import sha
11import simplejson
12import sys
13import time
14import urllib
15from StringIO import StringIO
16
17def 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
26def printError():
27    """Revert stderr to stdout, and print the contents of stderr"""
28    if isinstance(sys.stderr, StringIO):
29        print revertStandardError()
30
31if __name__ == '__main__':
32    import atexit
33    atexit.register(printError)
34    sys.stderr = StringIO()
35
36sys.path.append('/home/ecprice/.local/lib/python2.5/site-packages')
37
38import templates
39from Cheetah.Template import Template
40import sipb_xen_database
41from sipb_xen_database import Machine, CDROM, ctx, connect, MachineAccess, Type, Autoinstall
42import validation
43import cache_acls
44from webcommon import InvalidInput, CodeError, state
45import controls
46
47class 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
60checkpoint = Checkpoint()
61
62def jquote(string):
63    return "'" + string.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n') + "'"
64
65def 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
72def 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
80Template.sipb_xen_database = sipb_xen_database
81Template.helppopup = staticmethod(helppopup)
82Template.err = None
83
84class 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
101class 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
120DEFAULT_HEADERS = {'Content-Type': 'text/html'}
121
122def 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
128def 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
135def 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
145def 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
153def 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)
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
173def 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
216def 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
223def 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
269def 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
282def 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
304def 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
320def 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)
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
349def 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        print >> sys.stderr, machine, machine.administrator, kws['admin']
361        oldname = machine.name
362
363        if hasattr(validate, 'memory'):
364            machine.memory = validate.memory
365
366        if hasattr(validate, 'vmtype'):
367            machine.type = validate.vmtype
368
369        if hasattr(validate, 'disksize'):
370            disksize = validate.disksize
371            disk = machine.disks[0]
372            if disk.size != disksize:
373                olddisk[disk.guest_device_name] = disksize
374                disk.size = disksize
375                ctx.current.save(disk)
376
377        update_acl = False
378        if hasattr(validate, 'owner') and validate.owner != machine.owner:
379            machine.owner = validate.owner
380            update_acl = True
381        if hasattr(validate, 'name'):
382            machine.name = name
383        if hasattr(validate, 'admin') and validate.admin != machine.administrator:
384            machine.administrator = validate.admin
385            update_acl = True
386        if hasattr(validate, 'contact'):
387            machine.contact = validate.contact
388
389        ctx.current.save(machine)
390        if update_acl:
391            print >> sys.stderr, machine, machine.administrator
392            cache_acls.refreshMachine(machine)
393        transaction.commit()
394    except:
395        transaction.rollback()
396        raise
397    for diskname in olddisk:
398        controls.resizeDisk(oldname, diskname, str(olddisk[diskname]))
399    if hasattr(validate, 'name'):
400        controls.renameMachine(machine, oldname, validate.name)
401    return dict(user=username,
402                command="modify",
403                machine=machine)
404
405def modify(username, state, fields):
406    """Handler for modifying attributes of a machine."""
407    try:
408        modify_dict = modifyDict(username, state, fields)
409    except InvalidInput, err:
410        result = None
411        machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
412    else:
413        machine = modify_dict['machine']
414        result = 'Success!'
415        err = None
416    info_dict = infoDict(username, machine)
417    info_dict['err'] = err
418    if err:
419        for field in fields.keys():
420            setattr(info_dict['defaults'], field, fields.getfirst(field))
421    info_dict['result'] = result
422    return templates.info(searchList=[info_dict])
423
424
425def helpHandler(username, state, fields):
426    """Handler for help messages."""
427    simple = fields.getfirst('simple')
428    subjects = fields.getlist('subject')
429
430    help_mapping = {'ParaVM Console': """
431ParaVM machines do not support local console access over VNC.  To
432access the serial console of these machines, you can SSH with Kerberos
433to console.xvm.mit.edu, using the name of the machine as your
434username.""",
435                    'HVM/ParaVM': """
436HVM machines use the virtualization features of the processor, while
437ParaVM machines use Xen's emulation of virtualization features.  You
438want an HVM virtualized machine.""",
439                    'CPU Weight': """
440Don't ask us!  We're as mystified as you are.""",
441                    'Owner': """
442The owner field is used to determine <a
443href="help?subject=Quotas">quotas</a>.  It must be the name of a
444locker that you are an AFS administrator of.  In particular, you or an
445AFS group you are a member of must have AFS rlidwka bits on the
446locker.  You can check who administers the LOCKER locker using the
447commands 'attach LOCKER; fs la /mit/LOCKER' on Athena.)  See also <a
448href="help?subject=Administrator">administrator</a>.""",
449                    'Administrator': """
450The administrator field determines who can access the console and
451power on and off the machine.  This can be either a user or a moira
452group.""",
453                    'Quotas': """
454Quotas are determined on a per-locker basis.  Each locker may have a
455maximum of 512 megabytes of active ram, 50 gigabytes of disk, and 4
456active machines.""",
457                    'Console': """
458<strong>Framebuffer:</strong> At a Linux boot prompt in your VM, try
459setting <tt>fb=false</tt> to disable the framebuffer.  If you don't,
460your machine will run just fine, but the applet's display of the
461console will suffer artifacts.
462"""
463                    }
464
465    if not subjects:
466        subjects = sorted(help_mapping.keys())
467
468    d = dict(user=username,
469             simple=simple,
470             subjects=subjects,
471             mapping=help_mapping)
472
473    return templates.help(searchList=[d])
474
475
476def badOperation(u, e):
477    """Function called when accessing an unknown URI."""
478    raise CodeError("Unknown operation")
479
480def infoDict(username, machine):
481    """Get the variables used by info.tmpl."""
482    status = controls.statusInfo(machine)
483    checkpoint.checkpoint('Getting status info')
484    has_vnc = hasVnc(status)
485    if status is None:
486        main_status = dict(name=machine.name,
487                           memory=str(machine.memory))
488        uptime = None
489        cputime = None
490    else:
491        main_status = dict(status[1:])
492        start_time = float(main_status.get('start_time', 0))
493        uptime = datetime.timedelta(seconds=int(time.time()-start_time))
494        cpu_time_float = float(main_status.get('cpu_time', 0))
495        cputime = datetime.timedelta(seconds=int(cpu_time_float))
496    checkpoint.checkpoint('Status')
497    display_fields = """name uptime memory state cpu_weight on_reboot
498     on_poweroff on_crash on_xend_start on_xend_stop bootloader""".split()
499    display_fields = [('name', 'Name'),
500                      ('owner', 'Owner'),
501                      ('administrator', 'Administrator'),
502                      ('contact', 'Contact'),
503                      ('type', 'Type'),
504                      'NIC_INFO',
505                      ('uptime', 'uptime'),
506                      ('cputime', 'CPU usage'),
507                      ('memory', 'RAM'),
508                      'DISK_INFO',
509                      ('state', 'state (xen format)'),
510                      ('cpu_weight', 'CPU weight'+helppopup('CPU Weight')),
511                      ('on_reboot', 'Action on VM reboot'),
512                      ('on_poweroff', 'Action on VM poweroff'),
513                      ('on_crash', 'Action on VM crash'),
514                      ('on_xend_start', 'Action on Xen start'),
515                      ('on_xend_stop', 'Action on Xen stop'),
516                      ('bootloader', 'Bootloader options'),
517                      ]
518    fields = []
519    machine_info = {}
520    machine_info['name'] = machine.name
521    machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
522    machine_info['owner'] = machine.owner
523    machine_info['administrator'] = machine.administrator
524    machine_info['contact'] = machine.contact
525
526    nic_fields = getNicInfo(machine_info, machine)
527    nic_point = display_fields.index('NIC_INFO')
528    display_fields = (display_fields[:nic_point] + nic_fields +
529                      display_fields[nic_point+1:])
530
531    disk_fields = getDiskInfo(machine_info, machine)
532    disk_point = display_fields.index('DISK_INFO')
533    display_fields = (display_fields[:disk_point] + disk_fields +
534                      display_fields[disk_point+1:])
535
536    main_status['memory'] += ' MiB'
537    for field, disp in display_fields:
538        if field in ('uptime', 'cputime') and locals()[field] is not None:
539            fields.append((disp, locals()[field]))
540        elif field in machine_info:
541            fields.append((disp, machine_info[field]))
542        elif field in main_status:
543            fields.append((disp, main_status[field]))
544        else:
545            pass
546            #fields.append((disp, None))
547
548    checkpoint.checkpoint('Got fields')
549
550
551    max_mem = validation.maxMemory(machine.owner, state, machine, False)
552    checkpoint.checkpoint('Got mem')
553    max_disk = validation.maxDisk(machine.owner, machine)
554    defaults = Defaults()
555    for name in 'machine_id name administrator owner memory contact'.split():
556        setattr(defaults, name, getattr(machine, name))
557    defaults.type = machine.type.type_id
558    defaults.disk = "%0.2f" % (machine.disks[0].size/1024.)
559    checkpoint.checkpoint('Got defaults')
560    d = dict(user=username,
561             on=status is not None,
562             machine=machine,
563             defaults=defaults,
564             has_vnc=has_vnc,
565             uptime=str(uptime),
566             ram=machine.memory,
567             max_mem=max_mem,
568             max_disk=max_disk,
569             owner_help=helppopup("Owner"),
570             fields = fields)
571    return d
572
573def info(username, state, fields):
574    """Handler for info on a single VM."""
575    machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
576    d = infoDict(username, machine)
577    checkpoint.checkpoint('Got infodict')
578    return templates.info(searchList=[d])
579
580def unauthFront(_, _2, fields):
581    """Information for unauth'd users."""
582    return templates.unauth(searchList=[{'simple' : True}])
583
584mapping = dict(list=listVms,
585               vnc=vnc,
586               command=command,
587               modify=modify,
588               info=info,
589               create=create,
590               help=helpHandler,
591               unauth=unauthFront)
592
593def printHeaders(headers):
594    """Print a dictionary as HTTP headers."""
595    for key, value in headers.iteritems():
596        print '%s: %s' % (key, value)
597    print
598
599
600def getUser(environ):
601    """Return the current user based on the SSL environment variables"""
602    email = environ.get('SSL_CLIENT_S_DN_Email', None)
603    if email is None:
604        return None
605    if not email.endswith('@MIT.EDU'):
606        return None
607    return email[:-8]
608
609def main(operation, username, state, fields):
610    start_time = time.time()
611    fun = mapping.get(operation, badOperation)
612
613    if fun not in (helpHandler, ):
614        connect('postgres://sipb-xen@sipb-xen-dev.mit.edu/sipb_xen')
615    try:
616        checkpoint.checkpoint('Before')
617        output = fun(username, state, fields)
618        checkpoint.checkpoint('After')
619
620        headers = dict(DEFAULT_HEADERS)
621        if isinstance(output, tuple):
622            new_headers, output = output
623            headers.update(new_headers)
624        e = revertStandardError()
625        if e:
626            if isinstance(output, basestring):
627                sys.stderr = StringIO()
628                x = str(output)
629                print >> sys.stderr, x
630                print >> sys.stderr, 'XXX'
631                print >> sys.stderr, e
632                raise Exception()
633            output.addError(e)
634        printHeaders(headers)
635        output_string =  str(output)
636        checkpoint.checkpoint('output as a string')
637        print output_string
638        if fields.has_key('timedebug'):
639            print '<pre>%s</pre>' % cgi.escape(checkpoint)
640    except Exception, err:
641        if not fields.has_key('js'):
642            if isinstance(err, CodeError):
643                print 'Content-Type: text/html\n'
644                e = revertStandardError()
645                print error(operation, state.username, fields, err, e)
646                sys.exit(1)
647            if isinstance(err, InvalidInput):
648                print 'Content-Type: text/html\n'
649                e = revertStandardError()
650                print invalidInput(operation, state.username, fields, err, e)
651                sys.exit(1)
652        print 'Content-Type: text/plain\n'
653        print 'Uh-oh!  We experienced an error.'
654        print 'Please email xvm-dev@mit.edu with the contents of this page.'
655        print '----'
656        e = revertStandardError()
657        print e
658        print '----'
659        raise
660
661if __name__ == '__main__':
662    fields = cgi.FieldStorage()
663
664    if fields.has_key('sqldebug'):
665        import logging
666        logging.basicConfig()
667        logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
668        logging.getLogger('sqlalchemy.orm.unitofwork').setLevel(logging.INFO)
669
670    username = getUser(os.environ)
671    state.username = username
672    operation = os.environ.get('PATH_INFO', '')
673    if not operation:
674        print "Status: 301 Moved Permanently"
675        print 'Location: ' + os.environ['SCRIPT_NAME']+'/\n'
676        sys.exit(0)
677    if username is None:
678        operation = 'unauth'
679    if operation.startswith('/'):
680        operation = operation[1:]
681    if not operation:
682        operation = 'list'
683
684    if os.getenv("SIPB_XEN_PROFILE"):
685        import profile
686        profile.run('main(operation, username, state, fields)', 'log-'+operation)
687    else:
688        main(operation, username, state, fields)
Note: See TracBrowser for help on using the repository browser.