[2e46f35] | 1 | #!/usr/bin/env python |
---|
[d743d60] | 2 | |
---|
| 3 | import sys |
---|
| 4 | import os |
---|
| 5 | import tempfile |
---|
| 6 | import subprocess |
---|
| 7 | import xml.parsers.expat |
---|
| 8 | |
---|
[6bedbdba] | 9 | from deter import fedid |
---|
| 10 | from deter import topdl |
---|
| 11 | |
---|
[e83f2f2] | 12 | from federation.proof import proof |
---|
[d743d60] | 13 | from federation.remote_service import service_caller |
---|
| 14 | from federation.client_lib import client_opts, exit_with_fault, RPCException, \ |
---|
[e83f2f2] | 15 | wrangle_standard_options, do_rpc, get_experiment_names, save_certfile,\ |
---|
| 16 | log_authentication |
---|
[218ffe0] | 17 | from federation.proof import proof |
---|
[d743d60] | 18 | |
---|
| 19 | |
---|
| 20 | class image_opts(client_opts): |
---|
| 21 | def __init__(self): |
---|
| 22 | client_opts.__init__(self) |
---|
| 23 | self.add_option("--experiment_cert", dest="exp_certfile", |
---|
[62f3dd9] | 24 | action='callback', callback=self.expand_file, type='str', |
---|
| 25 | help="experiment certificate file") |
---|
[d743d60] | 26 | self.add_option("--experiment_name", dest="exp_name", |
---|
| 27 | type="string", help="human readable experiment name") |
---|
[62f3dd9] | 28 | self.add_option("--output", dest="outfile", |
---|
| 29 | action='callback', callback=self.expand_file, type='str', |
---|
[d743d60] | 30 | help="output image file") |
---|
| 31 | self.add_option("--format", dest="format", type="choice", |
---|
| 32 | choices=("jpg", "png", "dot", "svg"), |
---|
| 33 | help="Output file format override") |
---|
| 34 | self.add_option("--program", dest="neato", default=None, |
---|
| 35 | type="string", |
---|
| 36 | help="Program compatible with dot (from graphviz) used to " + \ |
---|
| 37 | "render image") |
---|
| 38 | self.add_option("--labels", dest='labels', action='store_true', |
---|
| 39 | default=True, help='Label nodes and edges') |
---|
| 40 | self.add_option("--no_labels", dest='labels', |
---|
| 41 | default=True, action='store_false', |
---|
| 42 | help='Label nodes and edges') |
---|
| 43 | self.add_option('--pixels', dest="pixels", default=None, |
---|
| 44 | type="int", |
---|
| 45 | help="Size of output in pixels (diagrams are square") |
---|
| 46 | self.add_option("--file", dest="file", |
---|
[62f3dd9] | 47 | action='callback', callback=self.expand_file, type='str', |
---|
[d743d60] | 48 | help="experiment description file") |
---|
[5c3d542] | 49 | self.add_option("--group", dest="group", action="append", default=[], |
---|
[1f356d3] | 50 | help="Group nodes by this attribute") |
---|
[d743d60] | 51 | |
---|
[5c3d542] | 52 | def make_subgraph(topelems, attrs): |
---|
[1f356d3] | 53 | """ |
---|
| 54 | Take a list of topdl elements (Computers and Substrates ) and partition |
---|
| 55 | them into groups based on the value of the given attribute. All computers |
---|
| 56 | with the same value for the attribute are in a subgraph. A substrate is in |
---|
| 57 | the subgraph if all its interfaces are, and in the "default" subgraph if |
---|
| 58 | not. |
---|
| 59 | """ |
---|
| 60 | |
---|
| 61 | def add_to_map(m, a, v): |
---|
| 62 | """ |
---|
| 63 | Add this value to the list in m[a] if that list exists, or create it. |
---|
| 64 | """ |
---|
| 65 | if a in m: m[a].append(v) |
---|
| 66 | else: m[a] = [ v] |
---|
| 67 | |
---|
[5c3d542] | 68 | if not attrs: return { } |
---|
| 69 | |
---|
[1f356d3] | 70 | sg = { } |
---|
[5c3d542] | 71 | attr, rest = attrs[0], attrs[1:] |
---|
[1f356d3] | 72 | for e in topelems: |
---|
| 73 | if isinstance(e, topdl.Computer): |
---|
| 74 | add_to_map(sg, e.get_attribute(attr) or 'default', e) |
---|
| 75 | elif isinstance(e, topdl.Substrate): |
---|
[0df7881] | 76 | # A little hairy. If the substrate itself has the attribute, use |
---|
| 77 | # that. Otherwise, make a set of the values of the given attribute |
---|
[1f356d3] | 78 | # for all the elements in the substrate's interfaces. If that set |
---|
| 79 | # has one element, put the substrate in that subgraph. Otherwise |
---|
| 80 | # put it in default. |
---|
| 81 | |
---|
[0df7881] | 82 | sa = e.get_attribute(attr) |
---|
| 83 | if sa is not None: |
---|
| 84 | add_to_map(sg, sa, e) |
---|
| 85 | else: |
---|
| 86 | atts = set([i.element.get_attribute(attr) or 'default' \ |
---|
| 87 | for i in e.interfaces]) |
---|
| 88 | |
---|
| 89 | if len(atts) == 1: add_to_map(sg, atts.pop(), e) |
---|
| 90 | else: add_to_map(sg, 'default', e) |
---|
[1f356d3] | 91 | |
---|
[5c3d542] | 92 | if rest: |
---|
| 93 | for k in sg.keys(): |
---|
| 94 | sg[k] = make_subgraph(sg[k], rest) |
---|
| 95 | |
---|
[1f356d3] | 96 | return sg |
---|
| 97 | |
---|
[5c3d542] | 98 | def output_subgraphs(sg, dotfile): |
---|
| 99 | |
---|
| 100 | # Construct subgraphs featurning nodes from those graphs |
---|
| 101 | for sn, nodes in sg.items(): |
---|
| 102 | # Output the computers and lans in subgraphs, if necessary. Subgraphs |
---|
| 103 | # treated as clusters are designated by "cluster" starting the name. |
---|
| 104 | # (The default subgraph is not treated as a cluster. |
---|
| 105 | if sn != 'default': |
---|
| 106 | print >>dotfile, 'subgraph "cluster-%s" {' % sn |
---|
| 107 | print >>dotfile, 'label="%s"' % sn |
---|
| 108 | else: |
---|
| 109 | print >>dotfile, 'subgraph {' |
---|
| 110 | print >>dotfile, 'color="#08c0f8"' |
---|
| 111 | print >>dotfile, 'clusterrank="local"' |
---|
| 112 | |
---|
| 113 | if isinstance(nodes, dict): output_subgraphs(nodes, dotfile) |
---|
| 114 | elif isinstance(nodes, list): |
---|
| 115 | # For each node in the subgraph, output its representation |
---|
| 116 | for i, n in enumerate(nodes): |
---|
| 117 | if isinstance(n, topdl.Computer): |
---|
| 118 | if n.name: |
---|
| 119 | print >>dotfile, \ |
---|
| 120 | '\t"%s" [shape=box,style=filled,\\' % n.name |
---|
| 121 | else: |
---|
| 122 | print >>dotfile, \ |
---|
| 123 | '\t"unnamed_node%d" [shape=box,style=filled,\\'\ |
---|
| 124 | % i |
---|
| 125 | print >>dotfile, \ |
---|
| 126 | '\t\tcolor=black,fillcolor="#60f8c0",regular=1]' |
---|
| 127 | elif isinstance(n, topdl.Substrate): |
---|
| 128 | print >>dotfile, '\t"%s" [shape=ellipse, style=filled,\\' \ |
---|
| 129 | % n.name |
---|
| 130 | print >>dotfile,\ |
---|
| 131 | '\t\tcolor=black,fillcolor="#80c0f8",regular=1]' |
---|
| 132 | print >>dotfile, '}' |
---|
| 133 | |
---|
| 134 | def gen_dot_topo(t, labels, dotfile, sg_attr): |
---|
[d743d60] | 135 | """ |
---|
| 136 | Write a dot description of the topology in t, a topdl.Topology to the open |
---|
| 137 | file dotfile. the gen_image function below has already put some of that |
---|
| 138 | file together. This function only generates the nodes and connections. If |
---|
| 139 | labels is true, label the nodes. |
---|
| 140 | """ |
---|
| 141 | |
---|
[1f356d3] | 142 | |
---|
[d743d60] | 143 | # The routine draws a circle for substrates with more than 2 interfaces |
---|
| 144 | # connected to it, so this makes lists of each of those. |
---|
| 145 | lans = [ s for s in t.substrates if len(s.interfaces) != 2] |
---|
| 146 | links = [ s for s in t.substrates if len(s.interfaces) == 2] |
---|
| 147 | |
---|
[1f356d3] | 148 | # Break into subgraphs by the attribute of one's given |
---|
| 149 | if sg_attr: sg = make_subgraph(t.elements + lans, sg_attr) |
---|
| 150 | else: sg = { 'default': t.elements + lans } |
---|
| 151 | |
---|
| 152 | |
---|
[5c3d542] | 153 | output_subgraphs(sg, dotfile) |
---|
[1f356d3] | 154 | |
---|
| 155 | # Pull the edges out ot the lans and links |
---|
[d743d60] | 156 | for l in lans: |
---|
| 157 | for i in l.interfaces: |
---|
| 158 | ip = i.get_attribute('ip4_address') |
---|
| 159 | if labels and ip: |
---|
| 160 | print >>dotfile, '\t"%s" -- "%s" [headlabel="%s"]' % \ |
---|
| 161 | (l.name, i.element.name, ip) |
---|
| 162 | else: |
---|
| 163 | print >>dotfile, '\t"%s" -- "%s"' % \ |
---|
| 164 | (l.name, i.element.name) |
---|
| 165 | |
---|
| 166 | for l in links: |
---|
| 167 | s, d = l.interfaces[0:2] |
---|
| 168 | sip = s.get_attribute('ip4_address') |
---|
| 169 | dip = d.get_attribute('ip4_address') |
---|
| 170 | if labels and sip and dip: |
---|
| 171 | print >>dotfile, \ |
---|
| 172 | ('\t"%s" -- "%s" [label="%s",taillabel="%s",' + \ |
---|
| 173 | 'headlabel="%s"]') % \ |
---|
| 174 | (s.element.name, d.element.name, l.name, |
---|
| 175 | sip, dip) |
---|
| 176 | else: |
---|
| 177 | print >>dotfile, '\t"%s" -- "%s" ' % \ |
---|
| 178 | (s.element.name, d.element.name) |
---|
| 179 | |
---|
[1f356d3] | 180 | def gen_image(top, file, fmt, neato, labels, pix=None, sg_attr=None): |
---|
[d743d60] | 181 | """ |
---|
| 182 | Create a dot formatted input file from the topdl.Topology in top and run |
---|
| 183 | dot on it, storing the output in a file named file. The image format it in |
---|
| 184 | fmt, and it has to be one of the formats understood by dot or neato. |
---|
| 185 | Labels is true if the graph should be noted with node names and addresses. |
---|
| 186 | Pix sets the sixe of the output in pixels (square images). Neato is a |
---|
| 187 | user-specified graph drawing programs that takes neato/dot compaitble |
---|
| 188 | inputs. If no neato is specified, the code looks for a suitable |
---|
| 189 | executable. Return the command's return code. |
---|
| 190 | """ |
---|
| 191 | |
---|
| 192 | # Open up a temporary file for dot to turn into a visualization. This |
---|
| 193 | # may raise an exception |
---|
| 194 | df, dotname = tempfile.mkstemp(prefix='fedd_client', suffix=".dot") |
---|
| 195 | dotfile = os.fdopen(df, 'w') |
---|
| 196 | |
---|
| 197 | # Look for neato if the executable is unspecified. |
---|
| 198 | if not neato: |
---|
[1f356d3] | 199 | # If a subgraph attribute has been specified, prefer dot to neato. dot |
---|
| 200 | # is more "natural" looking graphs, but neato segregates better. |
---|
| 201 | if sg_attr: |
---|
| 202 | search_list = ['/usr/bin/dot', '/usr/local/bin/dot', |
---|
| 203 | '/usr/bin/neato', '/usr/local/bin/neato'] |
---|
| 204 | else: |
---|
| 205 | search_list = ['/usr/bin/neato', '/usr/local/bin/neato', |
---|
| 206 | '/usr/bin/dot', '/usr/local/bin/dot'] |
---|
| 207 | |
---|
| 208 | for f in search_list: |
---|
[d743d60] | 209 | if os.access(f, os.X_OK): |
---|
| 210 | neato = f |
---|
| 211 | break |
---|
| 212 | else: |
---|
| 213 | raise RuntimeError("Cannot find graph rendering program") |
---|
| 214 | |
---|
| 215 | # Some arguments bases on size, outputformat, destination, etc. |
---|
| 216 | cmd = [neato, '-Gsplines=true'] |
---|
| 217 | if fmt != 'dot': cmd.append('-T%s' % fmt) |
---|
| 218 | if file: |
---|
| 219 | cmd.append('-o') |
---|
| 220 | cmd.append(file) |
---|
| 221 | cmd.append(dotname) |
---|
| 222 | |
---|
| 223 | nodes = len(top.elements) |
---|
| 224 | if nodes < 10: size = 5 |
---|
| 225 | elif nodes < 50: size = 10 |
---|
| 226 | else: size = 18 |
---|
| 227 | |
---|
| 228 | if pix: |
---|
| 229 | dpi = pix / size |
---|
| 230 | else: |
---|
| 231 | dpi = None |
---|
| 232 | |
---|
| 233 | # Begin composing the graph description |
---|
| 234 | print >>dotfile, "graph G {" |
---|
| 235 | if dpi: |
---|
[5c3d542] | 236 | print >>dotfile, \ |
---|
| 237 | '\tgraph [size="%i,%i", dpi="%i", ratio=fill, compound="true"];' \ |
---|
| 238 | % (size, size, dpi) |
---|
[d743d60] | 239 | else: |
---|
[5c3d542] | 240 | print >>dotfile, \ |
---|
| 241 | '\tgraph [size="%i,%i", ratio=fill, compound="true"];' \ |
---|
| 242 | % (size, size) |
---|
[d743d60] | 243 | |
---|
| 244 | if labels: |
---|
| 245 | print >>dotfile, '\tnode [fontname=arial,fontsize=9,label="\N"];' |
---|
| 246 | print >>dotfile, '\tedge [fontname=arial,fontsize=9];\n' |
---|
| 247 | else: |
---|
| 248 | print >>dotfile, '\tnode [label=""];' |
---|
| 249 | |
---|
[1f356d3] | 250 | gen_dot_topo(top, labels, dotfile, sg_attr=sg_attr) |
---|
[d743d60] | 251 | print >>dotfile, "}" |
---|
| 252 | dotfile.close() |
---|
| 253 | |
---|
| 254 | # Redirect the drawing program stderr, and draw |
---|
| 255 | dev_null = open("/dev/null", "w") |
---|
| 256 | rv = subprocess.call(cmd, stderr=dev_null) |
---|
| 257 | os.remove(dotname) |
---|
| 258 | dev_null.close() |
---|
| 259 | |
---|
| 260 | return rv |
---|
| 261 | |
---|
| 262 | def extract_topo_from_message(msg, serialize): |
---|
| 263 | """ |
---|
| 264 | Extract the topdl.Topology from either an Ns2Topdl or Info response. In |
---|
| 265 | the unlikely event that the message is misformatted, a RuntimeError is |
---|
| 266 | raised. If the overall script is only serializing messages, this routine |
---|
| 267 | probably returns None (unless do_rpc is modified to return a valid response |
---|
| 268 | when serializing only). |
---|
| 269 | """ |
---|
| 270 | if 'experimentdescription' in msg: |
---|
| 271 | if 'topdldescription' in msg['experimentdescription']: |
---|
| 272 | exp = msg['experimentdescription']['topdldescription'] |
---|
| 273 | return topdl.Topology(**exp) |
---|
| 274 | else: |
---|
| 275 | raise RuntimeError("Bad response: could not translate") |
---|
| 276 | elif serialize: |
---|
| 277 | return None |
---|
| 278 | else: |
---|
| 279 | raise RuntimeError("Bad response. %s" % e.message) |
---|
| 280 | |
---|
[5d854e1] | 281 | def get_experiment_topo(opts, cert, url): |
---|
[d743d60] | 282 | """ |
---|
| 283 | Get the topology of an existing experiment from a running fedd. That is, |
---|
| 284 | pull the experimentdescription from an Info operation. Extract the |
---|
| 285 | experiment identifier from opts and raise a RuntimeError if insufficient or |
---|
| 286 | ambiguous information is present there. The Topology is returned. |
---|
| 287 | """ |
---|
| 288 | |
---|
| 289 | # Figure out what experiment to retrieve |
---|
| 290 | if opts.exp_name and opts.exp_certfile: |
---|
| 291 | raise RuntimeError("Only one of --experiment_cert and " + \ |
---|
| 292 | "--experiment_name permitted") |
---|
| 293 | elif opts.exp_certfile: |
---|
| 294 | exp_id = { 'fedid': fedid(file=opts.exp_certfile) } |
---|
| 295 | elif opts.exp_name: |
---|
| 296 | exp_id = { 'localname' : opts.exp_name } |
---|
| 297 | else: |
---|
| 298 | raise RuntimeError("specify one of --experiment_cert, " + \ |
---|
| 299 | "--experiment_name, or --file") |
---|
| 300 | |
---|
| 301 | # Get the topology and return it |
---|
| 302 | req = { 'experiment': exp_id } |
---|
| 303 | resp_dict = do_rpc(req, |
---|
[5d854e1] | 304 | url, opts.transport, cert, opts.trusted, |
---|
[d743d60] | 305 | serialize_only=opts.serialize_only, |
---|
| 306 | tracefile=opts.tracefile, |
---|
| 307 | caller=service_caller('Info'), responseBody="InfoResponseBody") |
---|
| 308 | |
---|
[218ffe0] | 309 | p = proof.from_dict(resp_dict.get('proof', {})) |
---|
| 310 | if p and opts.auth_log: |
---|
| 311 | log_authentication(opts.auth_log, 'Image', 'succeeded', p) |
---|
[d743d60] | 312 | return extract_topo_from_message(resp_dict, opts.serialize_only) |
---|
| 313 | |
---|
[5d854e1] | 314 | def get_file_topo(opts, cert, url): |
---|
[d743d60] | 315 | """ |
---|
| 316 | Parse a topology file or convert an ns2 file into a topdl.Topology. XML |
---|
| 317 | encodings of the topology can be converted completely locally, but ns2 |
---|
| 318 | files must be converted by a fedd. The topology is returned or a |
---|
| 319 | RuntimeError, RPCError, or EnvironmentError (if the file is unreadable) is |
---|
| 320 | raised. |
---|
| 321 | """ |
---|
| 322 | # Try the easy case first. Note that if the file is unreadable rather than |
---|
| 323 | # unparsable, this attempt will raise an EnvironmentError. |
---|
| 324 | try: |
---|
| 325 | return topdl.topology_from_xml(filename=opts.file, top='experiment') |
---|
| 326 | except xml.parsers.expat.ExpatError, e: |
---|
| 327 | # Print an error message if debugging |
---|
| 328 | if opts.debug > 1: print >>sys.stderr, "XML parse failed: %s" %e |
---|
| 329 | |
---|
| 330 | # If we get here the parser above failed and we assume that the file is |
---|
| 331 | # ns2/tcl. Call out to the converter. The exceptions above can come out |
---|
| 332 | # of here, though EnvironmentErrors are unlikely. |
---|
| 333 | if not cert: |
---|
| 334 | raise RuntimeError("Couldn't find an identity certificate, " + \ |
---|
| 335 | "RPC (Ns2Topdl) needed.") |
---|
| 336 | |
---|
| 337 | contents = "".join([l for l in open(opts.file, "r")]) |
---|
| 338 | msg = { 'description': { 'ns2description': contents }, } |
---|
| 339 | if opts.debug > 1: print >>sys.stderr, msg |
---|
| 340 | resp_dict = do_rpc(msg, |
---|
[5d854e1] | 341 | url, opts.transport, cert, opts.trusted, |
---|
[d743d60] | 342 | serialize_only=opts.serialize_only, |
---|
| 343 | tracefile=opts.tracefile, |
---|
| 344 | caller=service_caller('Ns2Topdl'), |
---|
| 345 | responseBody="Ns2TopdlResponseBody") |
---|
| 346 | |
---|
| 347 | return extract_topo_from_message(resp_dict, opts.serialize_only) |
---|
| 348 | |
---|
| 349 | parser = image_opts() |
---|
| 350 | (opts, args) = parser.parse_args() |
---|
| 351 | |
---|
[52b6ebc] | 352 | # Note that if we're converting a local topdl file, the absence of a cert or |
---|
| 353 | # abac directory may not be a big deal. It's checked if needed below |
---|
[d743d60] | 354 | try: |
---|
[5d854e1] | 355 | cert, fid, url = wrangle_standard_options(opts) |
---|
[d743d60] | 356 | except RuntimeError, e: |
---|
| 357 | cert = None |
---|
[52b6ebc] | 358 | print >>sys.stderr, "Warning: %s" % e |
---|
[d743d60] | 359 | |
---|
| 360 | |
---|
| 361 | # Figure out where output is going and in what format. If format is given and |
---|
| 362 | # understood, that's the output format. Otherwise we try the last three |
---|
| 363 | # letters of the file as a suffix. If all that fails, we fall back to |
---|
| 364 | # dot/neato format. |
---|
| 365 | if opts.format and opts.outfile: |
---|
| 366 | fmt = opts.format |
---|
| 367 | file = opts.outfile |
---|
| 368 | elif not opts.format and opts.outfile: |
---|
| 369 | fmt = opts.outfile[-3:] |
---|
| 370 | if fmt not in ("png", "jpg", "dot", "svg"): |
---|
| 371 | print >>sys.stderr, \ |
---|
| 372 | "Unexpected file suffix (%s) and no format given, using %s" % \ |
---|
| 373 | (fmt, "dot") |
---|
| 374 | fmt = "dot" |
---|
| 375 | file = opts.outfile |
---|
| 376 | elif opts.format and not opts.outfile: |
---|
| 377 | fmt = opts.format |
---|
| 378 | file = None |
---|
| 379 | else: |
---|
| 380 | fmt="dot" |
---|
| 381 | file = None |
---|
| 382 | |
---|
| 383 | # Get the topology from a file or an experiment |
---|
| 384 | if not opts.file: |
---|
| 385 | if not cert: |
---|
| 386 | sys.exit("Couldn't find an identity certificate, RPC (Info) needed.") |
---|
| 387 | try: |
---|
[5d854e1] | 388 | top = get_experiment_topo(opts, cert, url) |
---|
[d743d60] | 389 | except RPCException, e: |
---|
[e83f2f2] | 390 | exit_with_fault(e, 'image', opts) |
---|
[d743d60] | 391 | except RuntimeError, e: |
---|
| 392 | sys.exit("%s" % e) |
---|
| 393 | else: |
---|
| 394 | if opts.exp_name or opts.exp_certfile: |
---|
| 395 | print >>sys.stderr, \ |
---|
| 396 | "Ignoring experiment name or certificate, reading %s" \ |
---|
| 397 | % opts.file |
---|
| 398 | try: |
---|
[5d854e1] | 399 | top = get_file_topo(opts, cert, url) |
---|
[d743d60] | 400 | except RPCException, e: |
---|
| 401 | print >>sys.stderr, "Cannot extract a topology from %s" % opts.file |
---|
[e83f2f2] | 402 | exit_with_fault(e, 'image', opts) |
---|
[d743d60] | 403 | except RuntimeError, e: |
---|
| 404 | sys.exit("Cannot extract a topology from %s: %s" % (opts.file, e)) |
---|
| 405 | except EnvironmentError, e: |
---|
| 406 | sys.exit("Cannot extract a topology from %s: %s" % (opts.file, e)) |
---|
| 407 | |
---|
| 408 | if not top: |
---|
| 409 | sys.exit("Cannot extract a topology from %s" % opts.file) |
---|
| 410 | |
---|
| 411 | # and make an image |
---|
| 412 | if not opts.serialize_only: |
---|
| 413 | try: |
---|
[1f356d3] | 414 | if gen_image(top, file, fmt, opts.neato, opts.labels, opts.pixels, |
---|
| 415 | opts.group) !=0: |
---|
[de2ef5c] | 416 | sys.exit("Error rendering graph (subcommand returned non-0)" + |
---|
| 417 | " Check output, this may be a non-fatal graphviz warning") |
---|
[d743d60] | 418 | except EnvironmentError, e: |
---|
| 419 | sys.exit("Failed to draw graph: %s" %e) |
---|
| 420 | except RuntimeError, e: |
---|
| 421 | sys.exit("Failed to draw graph: %s" %e) |
---|
| 422 | else: |
---|
| 423 | sys.exit(0) |
---|