summaryrefslogtreecommitdiff
path: root/indra/viewer_components/manager/apply_update.py
blob: 643e4ad2bc4d732c61086029b414bd45d568e50e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
#!/usr/bin/env python

# Copyright (c) 2016, Linden Research, Inc.
# 
# The following source code is PROPRIETARY AND CONFIDENTIAL. Use of
# this source code is governed by the Linden Lab Source Code Disclosure
# Agreement ("Agreement") previously entered between you and Linden
# Lab. By accessing, using, copying, modifying or distributing this
# software, you acknowledge that you have been informed of your
# obligations under the Agreement and agree to abide by those obligations.
# 
# ALL LINDEN LAB SOURCE CODE IS PROVIDED "AS IS." LINDEN LAB MAKES NO
# WARRANTIES, EXPRESS, IMPLIED OR OTHERWISE, REGARDING ITS ACCURACY,
# COMPLETENESS OR PERFORMANCE.
# $LicenseInfo:firstyear=2016&license=viewerlgpl$
# Copyright (c) 2016, Linden Research, Inc.
# $/LicenseInfo$

"""
@file   apply_update.py
@author coyot
@date   2016-06-28
"""

"""
Applies an already downloaded update.
"""

import argparse
import errno
import fnmatch
import InstallerUserMessage as IUM
import os
import os.path
import plistlib
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile

#Module level variables

#fnmatch expressions
LNX_REGEX = '*' + '.bz2'
MAC_REGEX = '*' + '.dmg'
MAC_APP_REGEX = '*' + '.app'
WIN_REGEX = '*' + '.exe'

#which install the updater is run from
INSTALL_DIR = os.path.abspath(os.path.dirname(os.path.realpath(__file__)))

#whether the update is to the INSTALL_DIR or not.  Most of the time this is the case.
IN_PLACE = True

BUNDLE_IDENTIFIER = "com.secondlife.indra.viewer"
# Magic OS directory name that causes Cocoa viewer to crash on OS X 10.7.5
# (see MAINT-3331)
STATE_DIR = os.path.join(os.environ["HOME"], "Library", "Saved Application State",
    BUNDLE_IDENTIFIER + ".savedState")

def silent_write(log_file_handle, text):
    #if we have a log file, write.  If not, do nothing.
    if (log_file_handle):
        #prepend text for easy grepping
        log_file_handle.write("APPLY UPDATE: " + text + "\n")

def get_filename(download_dir = None):
    #given a directory that supposedly has the download, find the installable
    #if you are on platform X and you give the updater a directory with an installable  
    #for platform Y, you are either trying something fancy or get what you deserve
    #or both
    for filename in os.listdir(download_dir):
        if (fnmatch.fnmatch(filename, LNX_REGEX) 
          or fnmatch.fnmatch(filename, MAC_REGEX) 
          or fnmatch.fnmatch(filename, WIN_REGEX)):            
            return os.path.join(download_dir, filename)
    #someone gave us a bad directory
    return None  
          
def try_dismount(log_file_handle = None, installable = None, tmpdir = None):
    #best effort cleanup try to dismount the dmg file if we have mounted one
    #the French judge gave it a 5.8
    try:
        #use the df command to find the device name
        #Filesystem   512-blocks   Used Available Capacity iused  ifree %iused  Mounted on
        #/dev/disk1s2    2047936 643280   1404656    32%   80408 175582   31%   /private/tmp/mnt/Second Life Installer
        command = ["df", os.path.join(tmpdir, "Second Life Installer")]
        output = subprocess.check_output(command)
        #first word of second line of df output is the device name
        mnt_dev = output.split('\n')[1].split()[0]
        #do the dismount
        command = ["hdiutil", "detach", "-force", mnt_dev]
        output = subprocess.check_output(command)
        silent_write(log_file_handle, "hdiutil detach succeeded")
        silent_write(log_file_handle, output)
    except Exception, e:
        silent_write(log_file_handle, "Could not detach dmg file %s.  Error messages: %s" % (installable, e.message))    

def apply_update(download_dir = None, platform_key = None, log_file_handle = None, in_place = True):
    #for lnx and mac, returns path to newly installed viewer
    #for win, return the name of the executable
    #returns None on failure for all three
    #throws an exception if it can't find an installable at all
    
    IN_PLACE = in_place
    
    installable = get_filename(download_dir)
    if not installable:
        #could not find the download
        raise ValueError("Could not find installable in " + download_dir)
    
    #apply update using the platform specific tools
    if platform_key == 'lnx':
        installed = apply_linux_update(installable, log_file_handle)
    elif platform_key == 'mac':
        installed = apply_mac_update(installable, log_file_handle)
    elif platform_key == 'win':
        installed = apply_windows_update(installable, log_file_handle)
    else:
        #wtf?
        raise ValueError("Unknown Platform: " + platform_key)
    
    if not installed:
        #only mark the download as done when everything is done
        done_filename = os.path.join(os.path.dirname(installable), ".done")
        open(done_filename, 'w+').close()
        
    return installed
    
def apply_linux_update(installable = None, log_file_handle = None):
    try:
        #untar to tmpdir
        tmpdir = tempfile.mkdtemp()
        tar = tarfile.open(name = installable, mode="r:bz2")
        tar.extractall(path = tmpdir)
        if IN_PLACE:
            #rename current install dir
            shutil.move(INSTALL_DIR,install_dir + ".bak")
        #mv new to current
        shutil.move(tmpdir, INSTALL_DIR)
        #delete tarball on success
        os.remove(installable)
    except Exception, e:
        silent_write(log_file_handle, "Update failed due to " + repr(e))
        return None
    return INSTALL_DIR

def apply_mac_update(installable = None, log_file_handle = None):
    #INSTALL_DIR is something like /Applications/Second Life Viewer.app/Contents/MacOS, need to jump up two levels for the install base
    install_base = os.path.dirname(INSTALL_DIR)
    install_base = os.path.dirname(install_base)
    
    #verify dmg file
    try:
        output = subprocess.check_output(["hdiutil", "verify", installable], stderr=subprocess.STDOUT)
        silent_write(log_file_handle, "dmg verification succeeded")
        silent_write(log_file_handle, output)
    except Exception, e:
        silent_write(log_file_handle, "Could not verify dmg file %s.  Error messages: %s" % (installable, e.message))
        return None
    #make temp dir and mount & attach dmg
    tmpdir = tempfile.mkdtemp()
    try:
        output = subprocess.check_output(["hdiutil", "attach", installable, "-mountroot", tmpdir])
        silent_write(log_file_handle, "hdiutil attach succeeded")
        silent_write(log_file_handle, output)
    except Exception, e:
        silent_write(log_file_handle, "Could not attach dmg file %s.  Error messages: %s" % (installable, e.message))
        return None
    #verify plist
    mounted_appdir = None
    for top_dir in os.listdir(tmpdir):
        for appdir in os.listdir(os.path.join(tmpdir, top_dir)):
            appdir = os.path.join(os.path.join(tmpdir, top_dir), appdir)
            if fnmatch.fnmatch(appdir, MAC_APP_REGEX):
                try:
                    plist = os.path.join(appdir, "Contents", "Info.plist")
                    CFBundleIdentifier = plistlib.readPlist(plist)["CFBundleIdentifier"]
                    mounted_appdir = appdir
                except:
                    #there is no except for this try because there are multiple directories that legimately don't have what we are looking for
                    pass
    if not mounted_appdir:
        silent_write(log_file_handle, "Could not find app bundle in dmg %s." % (installable,))
        return None        
    if CFBundleIdentifier != BUNDLE_IDENTIFIER:
        silent_write(log_file_handle, "Wrong or null bundle identifier for dmg %s.  Bundle identifier: %s" % (installable, CFBundleIdentifier))
        try_dismount(log_file_handle, installable, tmpdir)                   
        return None
    #do the install, finally
    if IN_PLACE:
        #  swap out old install directory
        bundlename = os.path.basename(mounted_appdir)
        silent_write(log_file_handle, "Updating %s" % bundlename)
        swapped_out = os.path.join(tmpdir, INSTALL_DIR.lstrip('/'))
        shutil.move(install_base, swapped_out)               
    else:
        silent_write(log_file_handle, "Installing %s" % install_base)
        
    #   copy over the new bits    
    try:
        shutil.copytree(mounted_appdir, install_base, symlinks=True)
        retcode = 0
    except Exception, e:
        # try to restore previous viewer
        if os.path.exists(swapped_out):
            silent_write(log_file_handle, "Install of %s failed, rolling back to previous viewer." % installable)
            shutil.move(swapped_out, installed_test)
            retcode = 1
    finally:
        try_dismount(log_file_handle, installable, tmpdir)
        if retcode:
            return None
    
    #see MAINT-3331        
    try:
        shutil.rmtree(STATE_DIR)  
    except Exception, e:
        #if we fail to delete something that isn't there, that's okay
        if e[0] == errno.ENOENT:
            pass
        else:
            raise e
    
    os.remove(installable)
    return install_base
    
def apply_windows_update(installable = None, log_file_handle = None):
    #the windows install is just running the NSIS installer executable
    #from VMP's perspective, it is a black box
    try:
        output = subprocess.check_output(installable, stderr=subprocess.STDOUT)
        silent_write(log_file_handle, "Install of %s succeeded." % installable)
        silent_write(log_file_handle, output)
    except subprocess.CalledProcessError, cpe:
        silent_write(log_file_handle, "%s failed with return code %s. Error messages: %s." % 
                     (cpe.cmd, cpe.returncode, cpe.message))
        return None
    #Due to the black box nature of the install, we have to derive the application path from the
    #name of the installable.  This is essentially reverse-engineering app_name()/app_name_oneword()
    #in viewer_manifest.py
    #the format of the filename is:  Second_Life_{Project Name}_A-B-C-XXXXXX_i686_Setup.exe
    #which deploys to C:\Program Files (x86)\SecondLifeProjectName\
    #so we want all but the last four phrases and tack on Viewer if there is no project
    if re.search('Project', installable):
        winstall = os.path.join("C:\\Program Files (x86)\\", "".join(installable.split("_")[:-3]))
    else:
        winstall = os.path.join("C:\\Program Files (x86)\\", "".join(installable.split("_")[:-3])+"Viewer")
    return winstall

def main():
    parser = argparse.ArgumentParser("Apply Downloaded Update")
    parser.add_argument('--dir', dest = 'download_dir', help = 'directory to find installable', required = True)
    parser.add_argument('--pkey', dest = 'platform_key', help =' OS: lnx|mac|win', required = True)
    parser.add_argument('--in_place', action = 'store_false', help = 'This upgrade is for a different channel', default = True)
    parser.add_argument('--log_file', dest = 'log_file', default = None, help = 'file to write messages to')
    args = parser.parse_args()
    
    if args.log_file:
        try:
            f = open(args.log_file,'w+') 
        except:
            print "%s could not be found or opened" % args.log_file
            sys.exit(1)
    
    IN_PLACE = args.in_place
    result = apply_update(download_dir = args.download_dir, platform_key = args.platform_key, log_file_handle = f)
    if not result:
        sys.exit("Update failed")
    else:
        sys.exit(0)


if __name__ == "__main__":
    main()