Statistics
| Branch: | Tag: | Revision:

mininet / util / vm / build.py @ d4279559

History | View | Annotate | Download (19.3 KB)

1
#!/usr/bin/python
2

    
3
"""
4
build.py: build a Mininet VM
5

6
Basic idea:
7

8
    prepare
9
    -> create base install image if it's missing
10
        - download iso if it's missing
11
        - install from iso onto image
12

13
    build
14
    -> create cow disk for new VM, based on base image
15
    -> boot it in qemu/kvm with text /serial console
16
    -> install Mininet
17

18
    test
19
    -> sudo mn --test pingall
20
    -> make test
21

22
    release
23
    -> shut down VM
24
    -> shrink-wrap VM
25
    -> upload to storage
26

27
"""
28

    
29
import os
30
from os import stat, path
31
from stat import ST_MODE
32
from os.path import abspath
33
from sys import exit, stdout
34
import re
35
from glob import glob
36
from subprocess import check_output, call, Popen
37
from tempfile import mkdtemp
38
from time import time, strftime, localtime
39
import argparse
40

    
41
pexpect = None  # For code check - imported dynamically
42

    
43
# boot can be slooooow!!!! need to debug/optimize somehow
44
TIMEOUT=600
45

    
46
# Some configuration
47
LogToConsole = False        # VM output to console rather than log file
48
SaveQCOW2 = False           # Save QCOW2 image rather than deleting it
49

    
50
VMImageDir = os.environ[ 'HOME' ] + '/vm-images'
51

    
52
isoURLs = {
53
    'precise32server':
54
    'http://mirrors.kernel.org/ubuntu-releases/12.04/'
55
    'ubuntu-12.04.3-server-i386.iso',
56
    'precise64server':
57
    'http://mirrors.kernel.org/ubuntu-releases/12.04/'
58
    'ubuntu-12.04.3-server-amd64.iso',
59
    'quantal32server':
60
    'http://mirrors.kernel.org/ubuntu-releases/12.10/'
61
    'ubuntu-12.10-server-i386.iso',
62
    'quantal64server':
63
    'http://mirrors.kernel.org/ubuntu-releases/12.10/'
64
    'ubuntu-12.10-server-amd64.iso',
65
    'raring32server':
66
    'http://mirrors.kernel.org/ubuntu-releases/13.04/'
67
    'ubuntu-13.04-server-i386.iso',
68
    'raring64server':
69
    'http://mirrors.kernel.org/ubuntu-releases/13.04/'
70
    'ubuntu-13.04-server-amd64.iso',
71
    'saucy32server':
72
    'http://mirrors.kernel.org/ubuntu-releases/13.10/'
73
    'ubuntu-13.10-server-i386.iso',
74
    'saucy64server':
75
    'http://mirrors.kernel.org/ubuntu-releases/13.10/'
76
    'ubuntu-13.10-server-amd64.iso',
77
}
78

    
79

    
80
LogStartTime = time()
81
LogFile = None
82

    
83
def log( *args, **kwargs ):
84
    """Simple log function: log( message along with local and elapsed time
85
       cr: False/0 for no CR"""
86
    cr = kwargs.get( 'cr', True )
87
    elapsed = time() - LogStartTime
88
    clocktime = strftime( '%H:%M:%S', localtime() )
89
    msg = ' '.join( str( arg ) for arg in args )
90
    output = '%s [ %.3f ] %s' % ( clocktime, elapsed, msg )
91
    if cr:
92
        print output
93
    else:
94
        print output,
95
    # Optionally mirror to LogFile
96
    if type( LogFile ) is file:
97
        if cr:
98
            output += '\n'
99
        LogFile.write( output )
100
        LogFile.flush()
101

    
102

    
103
def run( cmd, **kwargs ):
104
    "Convenient interface to check_output"
105
    log( '-', cmd )
106
    cmd = cmd.split()
107
    return check_output( cmd, **kwargs )
108

    
109

    
110
def srun( cmd, **kwargs ):
111
    "Run + sudo"
112
    return run( 'sudo ' + cmd, **kwargs )
113

    
114

    
115
def depend():
116
    "Install package dependencies"
117
    log( '* Installing package dependencies' )
118
    run( 'sudo apt-get -y update' )
119
    run( 'sudo apt-get install -y'
120
         ' kvm cloud-utils genisoimage qemu-kvm qemu-utils'
121
         ' e2fsprogs '
122
         ' landscape-client'
123
         ' python-setuptools mtools' )
124
    run( 'sudo easy_install pexpect' )
125

    
126

    
127
def popen( cmd ):
128
    "Convenient interface to popen"
129
    log( cmd )
130
    cmd = cmd.split()
131
    return Popen( cmd )
132

    
133

    
134
def remove( fname ):
135
    "rm -f fname"
136
    return run( 'rm -f %s' % fname )
137

    
138

    
139
def findiso( flavor ):
140
    "Find iso, fetching it if it's not there already"
141
    url = isoURLs[ flavor ]
142
    name = path.basename( url )
143
    iso = path.join( VMImageDir, name )
144
    if not path.exists( iso ) or ( stat( iso )[ ST_MODE ] & 0777 != 0444 ):
145
        log( '* Retrieving', url )
146
        run( 'curl -C - -o %s %s' % ( iso, url ) )
147
        if 'ISO' not in run( 'file ' + iso ):
148
            os.remove( iso )
149
            raise Exception( 'findiso: could not download iso from ' + url )
150
        # Write-protect iso, signaling it is complete
151
        log( '* Write-protecting iso', iso)
152
        os.chmod( iso, 0444 )
153
    log( '* Using iso', iso )
154
    return iso
155

    
156

    
157
def attachNBD( cow, flags='' ):
158
    """Attempt to attach a COW disk image and return its nbd device
159
        flags: additional flags for qemu-nbd (e.g. -r for readonly)"""
160
    # qemu-nbd requires an absolute path
161
    cow = abspath( cow )
162
    log( '* Checking for unused /dev/nbdX device ' )
163
    for i in range ( 0, 63 ):
164
        nbd = '/dev/nbd%d' % i
165
        # Check whether someone's already messing with that device
166
        if call( [ 'pgrep', '-f', nbd ] ) == 0:
167
            continue
168
        srun( 'modprobe nbd max-part=64' )
169
        srun( 'qemu-nbd %s -c %s %s' % ( flags, nbd, cow ) )
170
        print
171
        return nbd
172
    raise Exception( "Error: could not find unused /dev/nbdX device" )
173

    
174

    
175
def detachNBD( nbd ):
176
    "Detatch an nbd device"
177
    srun( 'qemu-nbd -d ' + nbd )
178

    
179

    
180
def extractKernel( image, flavor ):
181
    "Extract kernel and initrd from base image"
182
    kernel = path.join( VMImageDir, flavor + '-vmlinuz' )
183
    initrd = path.join( VMImageDir, flavor + '-initrd' )
184
    if path.exists( kernel ) and ( stat( image )[ ST_MODE ] & 0777 ) == 0444:
185
        # If kernel is there, then initrd should also be there
186
        return kernel, initrd
187
    log( '* Extracting kernel to', kernel )
188
    nbd = attachNBD( image, flags='-r' )
189
    print srun( 'partx ' + nbd )
190
    # Assume kernel is in partition 1/boot/vmlinuz*generic for now
191
    part = nbd + 'p1'
192
    mnt = mkdtemp()
193
    srun( 'mount -o ro %s %s' % ( part, mnt  ) )
194
    kernsrc = glob( '%s/boot/vmlinuz*generic' % mnt )[ 0 ]
195
    initrdsrc = glob( '%s/boot/initrd*generic' % mnt )[ 0 ]
196
    srun( 'cp %s %s' % ( initrdsrc, initrd ) )
197
    srun( 'chmod 0444 ' + initrd )
198
    srun( 'cp %s %s' % ( kernsrc, kernel ) )
199
    srun( 'chmod 0444 ' + kernel )
200
    srun( 'umount ' + mnt )
201
    run( 'rmdir ' + mnt )
202
    detachNBD( nbd )
203
    return kernel, initrd
204

    
205

    
206
def findBaseImage( flavor, size='8G' ):
207
    "Return base VM image and kernel, creating them if needed"
208
    image = path.join( VMImageDir, flavor + '-base.qcow2' )
209
    if path.exists( image ):
210
        # Detect race condition with multiple builds
211
        perms = stat( image )[ ST_MODE ] & 0777
212
        if perms != 0444:
213
            raise Exception( 'Error - %s is writable ' % image +
214
                            '; are multiple builds running?' )
215
    else:
216
        # We create VMImageDir here since we are called first
217
        run( 'mkdir -p %s' % VMImageDir )
218
        iso = findiso( flavor )
219
        log( '* Creating image file', image )
220
        run( 'qemu-img create -f qcow2 %s %s' % ( image, size ) )
221
        installUbuntu( iso, image )
222
        # Write-protect image, also signaling it is complete
223
        log( '* Write-protecting image', image)
224
        os.chmod( image, 0444 )
225
    kernel, initrd = extractKernel( image, flavor )
226
    log( '* Using base image', image, 'and kernel', kernel )
227
    return image, kernel, initrd
228

    
229

    
230
# Kickstart and Preseed files for Ubuntu/Debian installer
231
#
232
# Comments: this is really clunky and painful. If Ubuntu
233
# gets their act together and supports kickstart a bit better
234
# then we can get rid of preseed and even use this as a
235
# Fedora installer as well.
236
#
237
# Another annoying thing about Ubuntu is that it can't just
238
# install a normal system from the iso - it has to download
239
# junk from the internet, making this house of cards even
240
# more precarious.
241

    
242
KickstartText ="""
243
#Generated by Kickstart Configurator
244
#platform=x86
245

246
#System language
247
lang en_US
248
#Language modules to install
249
langsupport en_US
250
#System keyboard
251
keyboard us
252
#System mouse
253
mouse
254
#System timezone
255
timezone America/Los_Angeles
256
#Root password
257
rootpw --disabled
258
#Initial user
259
user mininet --fullname "mininet" --password "mininet"
260
#Use text mode install
261
text
262
#Install OS instead of upgrade
263
install
264
#Use CDROM installation media
265
cdrom
266
#System bootloader configuration
267
bootloader --location=mbr
268
#Clear the Master Boot Record
269
zerombr yes
270
#Partition clearing information
271
clearpart --all --initlabel
272
#Automatic partitioning
273
autopart
274
#System authorization infomation
275
auth  --useshadow  --enablemd5
276
#Firewall configuration
277
firewall --disabled
278
#Do not configure the X Window System
279
skipx
280
"""
281

    
282
# Tell the Ubuntu/Debian installer to stop asking stupid questions
283

    
284
PreseedText = """
285
d-i mirror/country string manual
286
d-i mirror/http/hostname string mirrors.kernel.org
287
d-i mirror/http/directory string /ubuntu
288
d-i mirror/http/proxy string
289
d-i partman/confirm_write_new_label boolean true
290
d-i partman/choose_partition select finish
291
d-i partman/confirm boolean true
292
d-i partman/confirm_nooverwrite boolean true
293
d-i user-setup/allow-password-weak boolean true
294
d-i finish-install/reboot_in_progress note
295
d-i debian-installer/exit/poweroff boolean true
296
"""
297

    
298
def makeKickstartFloppy():
299
    "Create and return kickstart floppy, kickstart, preseed"
300
    kickstart = 'ks.cfg'
301
    with open( kickstart, 'w' ) as f:
302
        f.write( KickstartText )
303
    preseed = 'ks.preseed'
304
    with open( preseed, 'w' ) as f:
305
        f.write( PreseedText )
306
    # Create floppy and copy files to it
307
    floppy = 'ksfloppy.img'
308
    run( 'qemu-img create %s 1440k' % floppy )
309
    run( 'mkfs -t msdos ' + floppy )
310
    run( 'mcopy -i %s %s ::/' % ( floppy, kickstart ) )
311
    run( 'mcopy -i %s %s ::/' % ( floppy, preseed ) )
312
    return floppy, kickstart, preseed
313

    
314

    
315
def archFor( filepath ):
316
    "Guess architecture for file path"
317
    name = path.basename( filepath )
318
    if '64' in name:
319
        arch = 'x86_64'
320
    elif 'i386' in name or '32' in name:
321
        arch = 'i386'
322
    else:
323
        log( "Error: can't discern CPU for file name", name )
324
        exit( 1 )
325
    return arch
326

    
327

    
328
def installUbuntu( iso, image, logfilename='install.log' ):
329
    "Install Ubuntu from iso onto image"
330
    kvm = 'qemu-system-' + archFor( iso )
331
    floppy, kickstart, preseed = makeKickstartFloppy()
332
    # Mount iso so we can use its kernel
333
    mnt = mkdtemp()
334
    srun( 'mount %s %s' % ( iso, mnt ) )
335
    kernel = path.join( mnt, 'install/vmlinuz' )
336
    initrd = path.join( mnt, 'install/initrd.gz' )
337
    cmd = [ 'sudo', kvm,
338
           '-machine', 'accel=kvm',
339
           '-nographic',
340
           '-netdev', 'user,id=mnbuild',
341
           '-device', 'virtio-net,netdev=mnbuild',
342
           '-m', '1024',
343
           '-k', 'en-us',
344
           '-fda', floppy,
345
           '-drive', 'file=%s,if=virtio' % image,
346
           '-cdrom', iso,
347
           '-kernel', kernel,
348
           '-initrd', initrd,
349
           '-append',
350
           ' ks=floppy:/' + kickstart +
351
           ' preseed/file=floppy://' + preseed +
352
           ' console=ttyS0' ]
353
    ubuntuStart = time()
354
    log( '* INSTALLING UBUNTU FROM', iso, 'ONTO', image )
355
    log( ' '.join( cmd ) )
356
    log( '* logging to', abspath( logfilename ) )
357
    params = {}
358
    if not LogToConsole:
359
        logfile = open( logfilename, 'w' )
360
        params = { 'stdout': logfile, 'stderr': logfile }
361
    vm = Popen( cmd, **params )
362
    log( '* Waiting for installation to complete')
363
    vm.wait()
364
    if not LogToConsole:
365
        logfile.close()
366
    elapsed = time() - ubuntuStart
367
    # Unmount iso and clean up
368
    srun( 'umount ' + mnt )
369
    run( 'rmdir ' + mnt )
370
    if vm.returncode != 0:
371
        raise Exception( 'Ubuntu installation returned error %d' %
372
                          vm.returncode )
373
    log( '* UBUNTU INSTALLATION COMPLETED FOR', image )
374
    log( '* Ubuntu installation completed in %.2f seconds ' % elapsed )
375

    
376

    
377
def boot( cow, kernel, initrd, logfile ):
378
    """Boot qemu/kvm with a COW disk and local/user data store
379
       cow: COW disk path
380
       kernel: kernel path
381
       logfile: log file for pexpect object
382
       returns: pexpect object to qemu process"""
383
    # pexpect might not be installed until after depend() is called
384
    global pexpect
385
    import pexpect
386
    arch = archFor( kernel )
387
    cmd = [ 'sudo', 'qemu-system-' + arch,
388
            '-machine accel=kvm',
389
            '-nographic',
390
            '-netdev user,id=mnbuild',
391
            '-device virtio-net,netdev=mnbuild',
392
            '-m 1024',
393
            '-k en-us',
394
            '-kernel', kernel,
395
            '-initrd', initrd,
396
            '-drive file=%s,if=virtio' % cow,
397
            '-append "root=/dev/vda1 init=/sbin/init console=ttyS0" ' ]
398
    cmd = ' '.join( cmd )
399
    log( '* BOOTING VM FROM', cow )
400
    log( cmd )
401
    vm = pexpect.spawn( cmd, timeout=TIMEOUT, logfile=logfile )
402
    return vm
403

    
404

    
405
def interact( vm ):
406
    "Interact with vm, which is a pexpect object"
407
    prompt = '\$ '
408
    log( '* Waiting for login prompt' )
409
    vm.expect( 'login: ' )
410
    log( '* Logging in' )
411
    vm.sendline( 'mininet' )
412
    log( '* Waiting for password prompt' )
413
    vm.expect( 'Password: ' )
414
    log( '* Sending password' )
415
    vm.sendline( 'mininet' )
416
    log( '* Waiting for login...' )
417
    vm.expect( prompt )
418
    log( '* Sending hostname command' )
419
    vm.sendline( 'hostname' )
420
    log( '* Waiting for output' )
421
    vm.expect( prompt )
422
    log( '* Fetching Mininet VM install script' )
423
    vm.sendline( 'wget '
424
                 'https://raw.github.com/mininet/mininet/master/util/vm/'
425
                 'install-mininet-vm.sh' )
426
    vm.expect( prompt )
427
    log( '* Running VM install script' )
428
    vm.sendline( 'bash install-mininet-vm.sh' )
429
    vm.expect ( 'password for mininet: ' )
430
    vm.sendline( 'mininet' )
431
    log( '* Waiting for script to complete... ' )
432
    # Gigantic timeout for now ;-(
433
    vm.expect( 'Done preparing Mininet', timeout=3600 )
434
    log( '* Completed successfully' )
435
    vm.expect( prompt )
436
    log( '* Testing Mininet' )
437
    vm.sendline( 'sudo mn --test pingall' )
438
    if vm.expect( [ ' 0% dropped', pexpect.TIMEOUT ], timeout=45 ) == 0:
439
        log( '* Sanity check OK' )
440
    else:
441
        log( '* Sanity check FAILED' )
442
    vm.expect( prompt )
443
    log( '* Making sure cgroups are mounted' )
444
    vm.sendline( 'sudo service cgroup-lite restart' )
445
    vm.expect( prompt )
446
    vm.sendline( 'sudo cgroups-mount' )
447
    vm.expect( prompt )
448
    log( '* Running make test' )
449
    vm.sendline( 'cd ~/mininet; sudo make test' )
450
    # We should change "make test" to report the number of
451
    # successful and failed tests. For now, we have to
452
    # know the time for each test, which means that this
453
    # script will have to change as we add more tests.
454
    for test in range( 0, 2 ):
455
        if vm.expect( [ 'OK', 'FAILED', pexpect.TIMEOUT ], timeout=180 ) == 0:
456
            log( '* Test', test, 'OK' )
457
        else:
458
            log( '* Test', test, 'FAILED' )
459
    vm.expect( prompt )
460
    log( '* Shutting down' )
461
    vm.sendline( 'sync; sudo shutdown -h now' )
462
    log( '* Waiting for EOF/shutdown' )
463
    vm.read()
464
    log( '* Interaction complete' )
465

    
466

    
467
def cleanup():
468
    "Clean up leftover qemu-nbd processes and other junk"
469
    call( [ 'sudo', 'pkill', '-9', 'qemu-nbd' ] )
470

    
471

    
472
def convert( cow, basename ):
473
    """Convert a qcow2 disk to a vmdk and put it a new directory
474
       basename: base name for output vmdk file"""
475
    vmdk = basename + '.vmdk'
476
    log( '* Converting qcow2 to vmdk' )
477
    run( 'qemu-img convert -f qcow2 -O vmdk %s %s' % ( cow, vmdk ) )
478
    return vmdk
479

    
480

    
481
# Template for virt-image(5) file
482

    
483
VirtImageXML = """
484
<?xml version="1.0" encoding="UTF-8"?>
485
<image>
486
    <name>%s</name>
487
    <domain>
488
        <boot type="hvm">
489
            <guest>
490
                <arch>%s<arch>
491
            </guest>
492
            <os>
493
                <loader dev="hd"/>
494
            </os>
495
            <drive disk="root.raw" target="hda"/>
496
        </boot>
497
        <devices>
498
            <vcpu>1</vcpu>
499
            <memory>%s</memory>
500
            <interface/>
501
            <graphics/>
502
        </devices>
503
    </domain>
504
        <storage>
505
            <disk file="%s" size="%s" format="vmdk"/>
506
        </storage>
507
</image>
508
"""
509

    
510

    
511
def genVirtImage( name, mem, diskname, disksize ):
512
    "Generate and return virt-image file name.xml"
513
    # Our strategy is going to be: create a
514
    # virt-image file and then use virt-convert to convert
515
    # it to an .ovf file
516
    xmlfile = name + '.xml'
517
    arch = archFor( name )
518
    xmltext = VirtImageXML % ( name, arch, mem, diskname, disksize )
519
    with open( xmlfile, 'w+' ) as f:
520
        f.write( xmltext )
521
    return xmlfile
522

    
523

    
524
def qcow2size( qcow2 ):
525
    "Return virtual disk size (in bytes) of qcow2 image"
526
    output = check_output( [ 'file', qcow2 ] )
527
    assert 'QCOW' in output
528
    bytes = int( re.findall( '(\d+) bytes', output )[ 0 ] )
529
    return bytes
530

    
531

    
532
def build( flavor='raring32server' ):
533
    "Build a Mininet VM; return vmdk and vdisk size"
534
    global LogFile
535
    start = time()
536
    date = strftime( '%y%m%d-%H-%M-%S', localtime())
537
    dir = 'mn-%s-%s' % ( flavor, date )
538
    try:
539
        os.mkdir( dir )
540
    except:
541
        raise Exception( "Failed to create build directory %s" % dir )
542
    os.chdir( dir )
543
    LogFile = open( 'build.log', 'w' )
544
    log( '* Logging to', abspath( LogFile.name ) )
545
    log( '* Created working directory', dir )
546
    image, kernel, initrd = findBaseImage( flavor )
547
    volume = flavor + '.qcow2'
548
    run( 'qemu-img create -f qcow2 -b %s %s' % ( image, volume ) )
549
    log( '* VM image for', flavor, 'created as', volume )
550
    if LogToConsole:
551
        logfile = stdout
552
    else:
553
        logfile = open( flavor + '.log', 'w+' )
554
    log( '* Logging results to', abspath( logfile.name ) )
555
    vm = boot( volume, kernel, initrd, logfile )
556
    interact( vm )
557
    size = qcow2size( volume )
558
    vmdk = convert( volume, basename=flavor )
559
    if not SaveQCOW2:
560
        log( '* Removing qcow2 volume', volume )
561
        os.remove( volume )
562
    log( '* Converted VM image stored as', abspath( vmdk ) )
563
    vimage = genVirtImage( flavor, mem=512, diskname=vmdk, disksize=size )
564
    log( '* Generated virtimage file as', vimage )
565
    end = time()
566
    elapsed = end - start
567
    log( '* Results logged to', abspath( logfile.name ) )
568
    log( '* Completed in %.2f seconds' % elapsed )
569
    log( '* %s VM build DONE!!!!! :D' % flavor )
570
    os.chdir( '..' )
571

    
572
def buildFlavorString():
573
    "Return string listing valid build flavors"
574
    return 'valid build flavors: %s' % ' '.join( sorted( isoURLs ) )
575

    
576

    
577
def parseArgs():
578
    "Parse command line arguments and run"
579
    global LogToConsole
580
    parser = argparse.ArgumentParser( description='Mininet VM build script',
581
                                      epilog=buildFlavorString() )
582
    parser.add_argument( '-v', '--verbose', action='store_true',
583
                        help='send VM output to console rather than log file' )
584
    parser.add_argument( '-d', '--depend', action='store_true',
585
                         help='install dependencies for this script' )
586
    parser.add_argument( '-l', '--list', action='store_true',
587
                         help='list valid build flavors' )
588
    parser.add_argument( '-c', '--clean', action='store_true',
589
                         help='clean up leftover build junk (e.g. qemu-nbd)' )
590
    parser.add_argument( '-q', '--qcow2', action='store_true',
591
                         help='save qcow2 image rather than deleting it' )
592
    parser.add_argument( 'flavor', nargs='*',
593
                         help='VM flavor(s) to build (e.g. raring32server)' )
594
    args = parser.parse_args()
595
    if args.depend:
596
        depend()
597
    if args.list:
598
        print buildFlavorString()
599
    if args.clean:
600
        cleanup()
601
    if args.verbose:
602
        LogToConsole = True
603
    for flavor in args.flavor:
604
        if flavor not in isoURLs:
605
            print "Unknown build flavor:", flavor
606
            print buildFlavorString()
607
            break
608
        # try:
609
        build( flavor )
610
        # except Exception as e:
611
        # log( '* BUILD FAILED with exception: ', e )
612
        # exit( 1 )
613
    if not ( args.depend or args.list or args.clean or args.flavor ):
614
        parser.print_help()
615

    
616

    
617
if __name__ == '__main__':
618
    parseArgs()