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

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

Use fcgi

  • Property svn:executable set to *
File size: 24.2 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, strict=True, **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, state, **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
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, 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
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        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
404def 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
424def 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': """
430ParaVM machines do not support local console access over VNC.  To
431access the serial console of these machines, you can SSH with Kerberos
432to console.xvm.mit.edu, using the name of the machine as your
433username.""",
434                    'HVM/ParaVM': """
435HVM machines use the virtualization features of the processor, while
436ParaVM machines use Xen's emulation of virtualization features.  You
437want an HVM virtualized machine.""",
438                    'CPU Weight': """
439Don't ask us!  We're as mystified as you are.""",
440                    'Owner': """
441The owner field is used to determine <a
442href="help?subject=Quotas">quotas</a>.  It must be the name of a
443locker that you are an AFS administrator of.  In particular, you or an
444AFS group you are a member of must have AFS rlidwka bits on the
445locker.  You can check who administers the LOCKER locker using the
446commands 'attach LOCKER; fs la /mit/LOCKER' on Athena.)  See also <a
447href="help?subject=Administrator">administrator</a>.""",
448                    'Administrator': """
449The administrator field determines who can access the console and
450power on and off the machine.  This can be either a user or a moira
451group.""",
452                    'Quotas': """
453Quotas are determined on a per-locker basis.  Each locker may have a
454maximum of 512 megabytes of active ram, 50 gigabytes of disk, and 4
455active machines.""",
456                    'Console': """
457<strong>Framebuffer:</strong> At a Linux boot prompt in your VM, try
458setting <tt>fb=false</tt> to disable the framebuffer.  If you don't,
459your machine will run just fine, but the applet's display of the
460console 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
475def badOperation(u, s, e):
476    """Function called when accessing an unknown URI."""
477    raise CodeError("Unknown operation")
478
479def infoDict(username, state, 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
572def 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, state, machine)
576    checkpoint.checkpoint('Got infodict')
577    return templates.info(searchList=[d])
578
579def unauthFront(_, _2, fields):
580    """Information for unauth'd users."""
581    return templates.unauth(searchList=[{'simple' : True}])
582
583mapping = 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
592def 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
599def 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
608class App:
609    def __init__(self, environ, start_response):
610        self.environ = environ
611        self.start = start_response
612
613        self.username = getUser(environ)
614        self.state = State(self.username)
615
616    def __iter__(self):
617        fields = cgi.FieldStorage(fp=self.environ['wsgi.input'], environ=self.environ)
618        print >> sys.stderr, fields
619        operation = self.environ.get('PATH_INFO', '')
620        if not operation:
621            self.start("301 Moved Permanently", [('Location',
622                                                  os.environ['SCRIPT_NAME']+'/')])
623            return
624        if self.username is None:
625            operation = 'unauth'
626        if operation.startswith('/'):
627            operation = operation[1:]
628        if not operation:
629            operation = 'list'
630        print 'Starting', operation
631
632        start_time = time.time()
633        fun = mapping.get(operation, badOperation)
634        try:
635            checkpoint.checkpoint('Before')
636            output = fun(self.username, self.state, fields)
637            checkpoint.checkpoint('After')
638
639            headers = dict(DEFAULT_HEADERS)
640            if isinstance(output, tuple):
641                new_headers, output = output
642                headers.update(new_headers)
643            print 'MOO2'
644            e = revertStandardError()
645            if e:
646                if isinstance(output, basestring):
647                    sys.stderr = StringIO()
648                    x = str(output)
649                    print >> sys.stderr, x
650                    print >> sys.stderr, 'XXX'
651                    print >> sys.stderr, e
652                    raise Exception()
653                output.addError(e)
654            output_string =  str(output)
655            checkpoint.checkpoint('output as a string')
656        except Exception, err:
657            if not fields.has_key('js'):
658                if isinstance(err, CodeError):
659                    self.start('500 Internal Server Error', [('Content-Type', 'text/html')])
660                    e = revertStandardError()
661                    s = error(operation, self.username, fields, err, e)
662                    yield str(s)
663                    return
664                if isinstance(err, InvalidInput):
665                    self.start('200 OK', [('Content-Type', 'text/html')])
666                    e = revertStandardError()
667                    yield str(invalidInput(operation, self.username, fields, err, e))
668                    return
669            self.start('500 Internal Server Error', [('Content-Type', 'text/plain')])
670            import traceback
671            yield '''Uh-oh!  We experienced an error.'
672Please email xvm-dev@mit.edu with the contents of this page.'
673----
674%s
675----
676%s
677----''' % (str(err), traceback.format_exc())
678        self.start('200 OK', headers.items())
679        yield output_string
680        if fields.has_key('timedebug'):
681            yield '<pre>%s</pre>' % cgi.escape(str(checkpoint))
682
683def constructor():
684    connect('postgres://sipb-xen@sipb-xen-dev.mit.edu/sipb_xen')
685    return App
686
687def main():
688    from flup.server.fcgi_fork import WSGIServer
689    WSGIServer(constructor()).run()
690
691if __name__ == '__main__':
692    main()
Note: See TracBrowser for help on using the repository browser.