/stdhome

To get this branch, use:
bzr branch http://bzr.ed.am/stdhome

« back to all changes in this revision

Viewing changes to lib/stdhome/deployment.py

  • Committer: Tim Marston
  • Date: 2014-04-18 14:43:42 UTC
  • Revision ID: tim@ed.am-20140418144342-9bba5zb0o6gqko98
fixed bug in FileMatcher causing it to blow up when ~/.stdhomerc wasn't present

Show diffs side-by-side

added added

removed removed

Lines of Context:
19
19
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
20
 
21
21
 
22
 
import os, re, shutil, filecmp
 
22
import os, re, shutil, filecmp, json
23
23
import the, util
 
24
from walker.copy_in import CopyInWalker
 
25
from walker.conflict import ConflictWalker
 
26
from walker.copy_out import CopyOutWalker
24
27
 
25
28
 
26
29
class Deployment:
27
30
 
28
31
 
29
32
        def __init__( self ):
 
33
                if the.repo is None:
 
34
                        raise RuntimeError( 'logic error: Deployment initialised when '
 
35
                                                                'the.repo is unset' )
30
36
                self.load_deployment_state()
31
37
                self.conflicts_checked = False
32
38
 
33
39
 
34
40
        def load_deployment_state( self ):
 
41
                """Load any deployment state.  If one is found then a deployment will be
 
42
                considered to be ongoing.
 
43
                """
35
44
 
36
45
                # list of files that were copied-in (or at least given the opportunity
37
46
        # to be) and updated through the vcs update.  This means that, while
38
 
        # there may have been conflicts during the update (which will be dealt
39
 
        # with in the repo), any arising conflicts with the filesystem are now
40
 
        # assumed to be because of the vcs update and can be ignored (that is to
41
 
        # say, we no longer care about the file in the home directory).  In
42
 
        # short, this is a list of files that it is safe to deploy, regardless
43
 
        # of the state of the filesystem.
44
 
                self.deploy_files = None
 
47
        # there may have been conflicts during the update (which the user will
 
48
        # have to have dealt with in the repo), any conflicts arising with these
 
49
        # files in the home directory are no longer important and can be
 
50
        # ignored.  In short, this is a list of files that can safely be
 
51
        # deployed, regardless of the state of the home directory.
 
52
                self.imported_files = None
 
53
 
 
54
                # list of files that were affected by a recent vcs update (so only these
 
55
                # files need to be checked for deployment conflicts or copied-out)
 
56
                self.affected_files = None
 
57
 
 
58
                # the revno that the repo was as prior to a recent update
 
59
                self.initial_revno = None
45
60
 
46
61
                # do we have a repo?
47
62
                if not os.path.exists( the.repo.full_dir ): return
49
64
                # if no file list exists, we're done
50
65
                file = os.path.join( the.full_mddir, "deploy.%s" % the.repo.name )
51
66
                if not os.path.isfile( file ):
52
 
                        if the.verbose: print "no deployment state found"
53
67
                        return
54
68
 
55
69
                # read the file list
56
 
                if the.verbose: print "loading deployment state"
 
70
                if the.verbose >= 1: print "deployment state found; loading"
57
71
                f = open( file, 'r' )
58
 
                self.deploy_files = f.read().splitlines()
 
72
                state = json.loads( f.read() )
 
73
 
 
74
                # unpack deployment state
 
75
                self.imported_files = state['imported_files'];
 
76
                self.initial_revno = state['initial_revno'];
 
77
                self.affected_files = state['affected_files'];
59
78
 
60
79
 
61
80
        def save_deployment_state( self ):
62
 
                if the.verbose: print "saving deployment state"
 
81
                """Save the current deployment state (so there will be a deployment ongoing).
 
82
                """
 
83
 
 
84
                if the.verbose >= 1: print "saving deployment state"
63
85
 
64
86
                # create metadata directory, as necessary
65
87
                if not os.path.isdir( the.full_mddir ):
66
88
                        os.mkdir( the.full_mddir )
67
89
 
 
90
                # pack deployment state
 
91
                state = {
 
92
                        'imported_files': self.imported_files,
 
93
                        'initial_revno': self.initial_revno,
 
94
                        'affected_files': self.affected_files,
 
95
                }
 
96
 
68
97
                # create file
69
98
                file = os.path.join( the.full_mddir, "deploy.%s" % the.repo.name )
70
99
                f = open( file, 'w' )
71
 
                f.write( '\n'.join( self.deploy_files ) + '\n' )
 
100
                f.write( json.dumps( state ) );
72
101
 
73
102
 
74
103
        def remove_deployment_state( self ):
 
104
                """Remove the current deployment state (so no deployment will be ongoing).
 
105
                """
75
106
 
76
 
                # delete it, if it exists
 
107
                # check it exists
77
108
                file = os.path.join( the.full_mddir, "deploy.%s" % the.repo.name )
78
109
                if( os.path.isfile( file ) ):
 
110
 
 
111
                        # delete it
 
112
                        if the.verbose >= 1: print "removing deployment state"
79
113
                        os.unlink( file )
80
114
 
81
115
 
82
116
        def is_ongoing( self ):
83
 
                return False if self.deploy_files is None else True
 
117
                """Is there a deployment currently ongoing?
 
118
                """
 
119
 
 
120
                return False if self.imported_files is None else True
84
121
 
85
122
 
86
123
        def check_ongoing( self, ongoing = True ):
 
124
                """Check that a deployment either is or is not ongoing and raise an error if
 
125
                not.
 
126
                """
 
127
 
87
128
                if( ongoing ):
88
 
                        if self.deploy_files is None:
 
129
                        if self.imported_files is None:
89
130
                                raise self.DeploymentOngoing( False )
90
131
                else:
91
 
                        if self.deploy_files is not None:
 
132
                        if self.imported_files is not None:
92
133
                                raise self.DeploymentOngoing( True )
93
134
 
94
135
 
95
 
        def confirm( self, message ):
96
 
                # TODO: write interactive confirmation routine
97
 
                return False
98
 
 
99
 
 
100
 
        def walk_repo( self, dir_func = None, file_func = None, link_func = None,
101
 
                                   relative_dir = '', ignore_errors = False ):
102
 
                """Walk through all files and directories in the repo, passing the relative path
103
 
                to each to the provided functions.
104
 
                """
105
 
                full_dir = os.path.join( the.repo.full_dir, relative_dir )
106
 
                for file in os.listdir( full_dir ):
107
 
                        relative_file = os.path.join( relative_dir, file )
108
 
 
109
 
                        # skip some stuff
110
 
                        if relative_file == '.bzr': continue
111
 
 
112
 
                        repo_file = os.path.join( the.repo.full_dir, relative_file )
113
 
                        fs_file = os.path.join( the.full_fsdir, relative_file )
114
 
                        if os.path.islink( repo_file ):
115
 
                                if link_func is not None:
116
 
                                        link_func( relative_file, repo_file, fs_file )
117
 
                        elif os.path.isfile( repo_file ):
118
 
                                if file_func is not None:
119
 
                                        file_func( relative_file, repo_file, fs_file )
120
 
                        elif os.path.isdir( repo_file ):
121
 
                                if dir_func is not None:
122
 
                                        dir_func( relative_path, repo_file, fs_file )
123
 
                                # recurse in directories
124
 
                                self.walk_repo( dir_func, file_func, link_func, relative_dir,
125
 
                                                                ignore_errors )
126
 
                        elif not ignore_errors:
127
 
                                raise RuntimeError(
128
 
                                        'repo contains unknown/missing entity' )
129
 
 
130
 
 
131
 
        def copy_in( self ):
132
 
 
133
 
                def copy_in_dir( relative_file, dst, src ):
134
 
                        self.deploy_files.append( relative_file )
135
 
 
136
 
                        # if src doesn't exist, delete it from the repo
137
 
                        if not os.path.lexists( src ):
138
 
                                if the.verbose: print " _<d " + relative_file
139
 
                                shutil.rmtree( dst )
140
 
 
141
 
                        # if src is a directory, copy permissions, as necessary
142
 
                        elif os.path.isdir( src ):
143
 
                                # TODO: should check permissions and only do as necessary
144
 
                                if the.verbose: print " d<d " + relative_file
145
 
                                shutil.copystat( src, dst )
146
 
 
147
 
                        # TODO: serious differences in between ~/ and repo (e.g., files in
148
 
                        # one that are directories in the other) should be ignored (e.g.,
149
 
                        # not copied-in).  And the stuff that is ignored during copy-in
150
 
                        # should also be ignored during copy-out and must not be added to
151
 
                        # the deploy_files list.  Since these ignored files/directories are
152
 
                        # transparent to the user, they should have to explicitly permit
153
 
                        # them via an ignore file (e.g., ~/.stdhome/.ignore, akin to bzr's
154
 
                        # .bzrignore file).  If these serious differences are not matched by
155
 
                        # the ignore file, an error should show (which will requie a
156
 
                        # separate "check" walk of the repo, as is done in copy_out).
157
 
                        else:
158
 
                                raise self.Conflict( "%s differs too severely from the repo" %
159
 
                                                                         os.path.join( the.fsdir, relative_file ) )
160
 
 
161
 
                def copy_in_file( relative_file, dst, src ):
162
 
                        self.deploy_files.append( relative_file )
163
 
 
164
 
                        # if src doesn't exist, delete it in the repo
165
 
                        if not os.path.lexists( src ):
166
 
                                if the.verbose: print " ?<f " + relative_file
167
 
                                os.unlink( dst )
168
 
 
169
 
                        # if src is a symlink, replace it in repo
170
 
                        elif os.path.islink( src ):
171
 
                                if the.verbose: print " l<f " + relative_file
172
 
                                os.unlink( dst )
173
 
                                os.symlink( os.readlink( src ), dst )
174
 
 
175
 
                        # if src is a file, replace it if different
176
 
                        elif os.path.isfile( src ):
177
 
                                if not filecmp.cmp( src, dst ):
178
 
                                        if the.verbose: print " f<f " + relative_file
179
 
                                        os.unlink( dst )
180
 
                                        shutil.copy( src, dst )
181
 
                                        shutil.copystat( src, dst )
182
 
                                else:
183
 
                                        if the.verbose: print " f=f " + relative_file
184
 
 
185
 
                        # TODO: serious differences in between ~/ and repo (e.g., files in
186
 
                        # one that are directories in the other) should be ignored (e.g.,
187
 
                        # not copied-in).  And the stuff that is ignored during copy-in
188
 
                        # should also be ignored during copy-out and must not be added to
189
 
                        # the deploy_files list.  Since these ignored files/directories are
190
 
                        # transparent to the user, they should have to explicitly permit
191
 
                        # them via an ignore file (e.g., ~/.stdhome/.ignore, akin to bzr's
192
 
                        # .bzrignore file).  If these serious differences are not matched by
193
 
                        # the ignore file, an error should show (which will requie a
194
 
                        # separate "check" walk of the repo, as is done in copy_out).
195
 
                        else:
196
 
                                raise self.Conflict( "%s differs too severely from the repo" %
197
 
                                                                         os.path.join( the.fsdir, relative_file ) )
198
 
 
199
 
                def copy_in_link( relative_file, dst, src ):
200
 
                        self.deploy_files.append( relative_file )
201
 
 
202
 
                        # if src doesn't exist, delete it in the repo
203
 
                        if not os.path.lexists( src ):
204
 
                                if the.verbose: print " _<l " + relative_file
205
 
                                os.unlink( dst )
206
 
 
207
 
                        # if src is a symlink, replace it if different in repo
208
 
                        elif os.path.islink( src ):
209
 
                                if os.readlink( src ) != os.readlink( dst ):
210
 
                                        if the.verbose: print " l<l " + relative_file
211
 
                                        os.unlink( dst )
212
 
                                        os.symlink( os.readlink( src ), dst )
213
 
                                else:
214
 
                                        if the.verbose: print " l=l " + relative_file
215
 
 
216
 
                        # if src is a file, replace it in repo
217
 
                        elif os.path.isfile( src ):
218
 
                                if the.verbose: print " f<l " + relative_file
219
 
                                os.unlink( dst )
220
 
                                shutil.copy( src, dst )
221
 
                                shutil.copystat( src, dst )
222
 
 
223
 
                        # TODO: serious differences in between ~/ and repo (e.g., files in
224
 
                        # one that are directories in the other) should be ignored (e.g.,
225
 
                        # not copied-in).  And the stuff that is ignored during copy-in
226
 
                        # should also be ignored during copy-out and must not be added to
227
 
                        # the deploy_files list.  Since these ignored files/directories are
228
 
                        # transparent to the user, they should have to explicitly permit
229
 
                        # them via an ignore file (e.g., ~/.stdhome/.ignore, akin to bzr's
230
 
                        # .bzrignore file).  If these serious differences are not matched by
231
 
                        # the ignore file, an error should show (which will requie a
232
 
                        # separate "check" walk of the repo, as is done in copy_out).
233
 
                        else:
234
 
                                raise self.Conflict( "%s differs too severely from the repo" %
235
 
                                                                         os.path.join( the.fsdir, relative_file ) )
 
136
        def get_initial_revno( self ):
 
137
                """Get the initial revision identifier from the deployment state.
 
138
                """
 
139
 
 
140
                return self.initial_revno
 
141
 
 
142
 
 
143
        def copy_in( self, initial_revno = None ):
 
144
                """Copy-in changes from the home directory to the repository.  When finished,
 
145
                the state of deployment is saved, meaning that a deployment is ongoing.
 
146
                """
236
147
 
237
148
                # check we don't already have a file list
238
149
                self.check_ongoing( False )
239
150
 
240
 
                # new file list
241
 
                self.deploy_files = list()
242
 
 
243
 
                # if the repo doesn't exist, we're done
244
 
                if not os.path.exists( the.repo.full_dir ): return
245
 
 
246
 
                # copy in
247
 
                if the.verbose: print "importing files"
248
 
                self.walk_repo( dir_func = copy_in_dir, file_func = copy_in_file,
249
 
                                           link_func = copy_in_link, ignore_errors = True )
 
151
                # if the repo doesn't exist, we have an empty file list
 
152
                if not os.path.exists( the.repo.full_dir ):
 
153
                        self.imported_files = list()
 
154
                else:
 
155
                        # copy in
 
156
                        if the.verbose >= 1: print "importing files..."
 
157
                        walker = CopyInWalker()
 
158
                        walker.walk()
 
159
                        self.imported_files = walker.walk_list
 
160
 
 
161
                        # obtain initial revno
 
162
                        self.initial_revno = the.repo.vcs.get_revno()
250
163
 
251
164
                # save state
252
165
                self.save_deployment_state()
253
166
 
254
167
 
 
168
        def get_conflicts( self, affected_files = None ):
 
169
                """Check to see if there are any deployment conflicts.  If a list of affected
 
170
                files is supplied, then only those files are checked (and they are also
 
171
                saved with the deployment state).  Otherwise, all files in the
 
172
                repository are checked.
 
173
                """
 
174
 
 
175
                # check we have a file list
 
176
                self.check_ongoing( True )
 
177
 
 
178
                # set updated files
 
179
                if affected_files is not None:
 
180
                        self.affected_files = affected_files
 
181
                        self.save_deployment_state()
 
182
 
 
183
                # check for deployment conflicts
 
184
                walker = ConflictWalker( self.imported_files, self.affected_files )
 
185
                walker.walk()
 
186
 
 
187
                self.conflicts_checked = True
 
188
                return walker.changed + walker.obstructed
 
189
 
 
190
 
255
191
        def copy_out( self ):
256
 
 
257
 
                def copy_out_dir( relative_file, src, dst ):
258
 
 
259
 
                        # if dst doesn't exist, create it
260
 
                        if not os.path.lexists( dst ):
261
 
                                if the.verbose: print " d>_ " + relative_file
262
 
                                os.mkdir( dst )
263
 
                                shutil.copystat( src, dst )
264
 
 
265
 
                        # if dst is a file/symlink, replace it
266
 
                        elif os.path.isfile( dst ):
267
 
                                if the.verbose: print " d>f " + relative_file
268
 
                                os.unlink( dst )
269
 
                                os.mkdir( dst )
270
 
                                shutil.copystat( src, dst )
271
 
 
272
 
                        # if dst is a directory, copy permissions as required
273
 
                        elif os.path.isdir( dst ):
274
 
                                # TODO: should check permission and only do as necessary
275
 
                                if the.verbose: print " d>d " + relative_file
276
 
                                shutil.copystat( src, dst )
277
 
 
278
 
                        else:
279
 
                                raise NotImplementedError()
280
 
 
281
 
                def copy_out_file( relative_file, src, dst ):
282
 
 
283
 
                        # if dst doesn't exist, copy
284
 
                        if not os.path.lexists( dst ):
285
 
                                if the.verbose: print " f>_ " + relative_file
286
 
 
287
 
                                shutil.copy( src, dst )
288
 
                                shutil.copystat( src, dst )
289
 
 
290
 
                        # if dst is a symlink, replace it
291
 
                        elif os.path.islink( dst ):
292
 
                                if the.verbose: print " f>l " + relative_file
293
 
                                os.unlink( dst )
294
 
                                shutil.copy( src, dst )
295
 
                                shutil.copystat( src, dst )
296
 
 
297
 
                        # if dst is a file, replace it if different
298
 
                        elif os.path.isfile( dst ):
299
 
                                if not filecmp.cmp( src, dst ):
300
 
                                        if the.verbose: print " f>f " + relative_file
301
 
                                        os.unlink( dst )
302
 
                                        shutil.copy( src, dst )
303
 
                                        shutil.copystat( src, dst )
304
 
                                else:
305
 
                                        if the.verbose: print " f=f " + relative_file
306
 
 
307
 
                        # if dst is a directory, replace it
308
 
                        elif os.path.isdir( dst ):
309
 
                                if the.verbose: print " f>d " + relative_file
310
 
                                shutil.rmtree( dst )
311
 
                                shutil.copy( src, dst )
312
 
                                shutil.copystat( src, dst )
313
 
 
314
 
                        else:
315
 
                                raise NotImplementedError()
316
 
 
317
 
                def copy_out_link( relative_file, src, dst ):
318
 
 
319
 
                        # if dst doesn't exist, copy
320
 
                        if not os.path.lexists( dst ):
321
 
                                if the.verbose: print " l>_ " + relative_file
322
 
                                os.symlink( os.readlink( src ), dst )
323
 
 
324
 
                        # if dst is a symlink, replace it if different
325
 
                        elif os.path.islink( dst ):
326
 
                                if os.readlink( src ) != os.readlink( dst ):
327
 
                                        if the.verbose: print " l>l " + relative_file
328
 
                                        os.unlink( dst )
329
 
                                        os.symlink( os.readlink( src ), dst )
330
 
                                else:
331
 
                                        if the.verbose: print " l=l " + relative_file
332
 
 
333
 
                        # if dst is a file, replace it
334
 
                        elif os.path.isfile( dst ):
335
 
                                if the.verbose: print " l>f " + relative_file
336
 
                                os.unlink( dst )
337
 
                                os.symlink( os.readlink( src ), dst )
338
 
 
339
 
                        # if dst is a directory, replace it
340
 
                        elif os.path.isdir( dst ):
341
 
                                if the.verbose: print " l>d " + relative_file
342
 
                                shutil.rmtree( dst )
343
 
                                os.symlink( os.readlink( src ), dst )
344
 
 
345
 
                        else:
346
 
                                raise NotImplementedError()
 
192
                """Copy-out changed files from the repository to the home directory.  If the
 
193
                deployment state includes a list of affected files, then only those
 
194
                files are copied-out.
 
195
                """
347
196
 
348
197
                # check we have a file list
349
198
                self.check_ongoing( True )
350
199
 
351
 
                # we should already have handled conflicts
 
200
                # check that deployment conflicts have been checked-for
352
201
                if not self.conflicts_checked:
353
 
                        raise RuntimeError(
354
 
                                'logic error: conflicts should have been checked!' )
 
202
                        raise RuntimeError('logic error: deployment conflicts unchecked' )
355
203
 
356
204
                # copy out
357
 
                if the.verbose: print "exporting files"
358
 
                self.walk_repo( dir_func = copy_out_dir, file_func = copy_out_file,
359
 
                                           link_func = copy_out_link, ignore_errors = True )
 
205
                if the.verbose >= 1: print "exporting files..."
 
206
                walker = CopyOutWalker( self.affected_files )
 
207
                walker.walk()
360
208
 
361
209
                # clear state
362
210
                self.remove_deployment_state()
363
211
 
364
212
 
365
 
        def get_conflicts( self ):
366
 
 
367
 
                def check_dir( relative_file, src, dst ):
368
 
 
369
 
                        # files that existed at copy-in can be copied-out and overwritten
370
 
                        if self.deploy_files is not None and \
371
 
                           relative_file in self.deploy_files: return
372
 
 
373
 
                        # files that don't exist in the filesystem can be copied-out
374
 
                        if not os.path.lexists( dst ): return
375
 
 
376
 
                        # accept/reject existing files
377
 
                        if os.path.islink( dst ):
378
 
                                self.files.append( "%s already exists (as a symlink)" %
379
 
                                                                   os.path.join( the.fsdir, relative_file ) )
380
 
                        elif os.path.isfile( dst ):
381
 
                                self.files.append( "%s already exists (as a file)" %
382
 
                                                                   os.path.join( the.fsdir, relative_file ) )
383
 
                        elif os.path.isdir( dst ):
384
 
                                return
385
 
                        else:
386
 
                                self.files.append( "%s already exists" %
387
 
                                                                   os.path.join( the.fsdir, relative_file ) )
388
 
 
389
 
                def check_file( relative_file, src, dst ):
390
 
 
391
 
                        # files that existed at copy-in can be copied-out and overwritten
392
 
                        if self.deploy_files is not None and \
393
 
                           relative_file in self.deploy_files: return
394
 
 
395
 
                        # files that don't exist in the filesystem can be copied-out
396
 
                        if not os.path.lexists( dst ): return
397
 
 
398
 
                        # accept/reject existing files
399
 
                        if os.path.isfile( dst ):
400
 
                                self.files.append( "%s already exists" %
401
 
                                                                   os.path.join( the.fsdir, relative_file ) )
402
 
                        elif os.path.isdir( dst ):
403
 
                                self.files.append( "%s already exists (as a directory)" %
404
 
                                                                   os.path.join( the.fsdir, relative_file ) )
405
 
                        else:
406
 
                                self.files.append( "%s already exists" %
407
 
                                                                   os.path.join( the.fsdir, relative_file ) )
408
 
 
409
 
                self.conflicts_checked = True
410
 
 
411
 
                # check for conflicts
412
 
                self.files = list()
413
 
                self.walk_repo( dir_func = check_dir, file_func = check_file,
414
 
                                           link_func = check_file )
415
 
                return self.files
416
 
 
417
 
 
418
213
        class DeploymentOngoing( the.program.FatalError ):
419
214
 
420
215
                def __init__( self, ongoing ):
424
219
                                self.msg = "there is no ongoing deployment"
425
220
 
426
221
 
427
 
        class Conflict( Exception ):
 
222
        class CopyInConflicts( the.program.FatalError ):
428
223
 
429
 
                def __init__( self, message ):
430
 
                        self.msg = message
 
224
                def __init__( self, conflicts ):
 
225
                        self.conflicts = conflicts