/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-02-12 21:51:08 UTC
  • Revision ID: tim@ed.am-20140212215108-stk5z0nlvgpi4oa8
added bzr as a vcs backend; finished init command; implemented deployment

Show diffs side-by-side

added added

removed removed

19
19
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
20
 
21
21
 
22
 
import os, re, shutil, filecmp, json
 
22
import os, re, shutil, filecmp
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
27
24
 
28
25
 
29
26
class Deployment:
30
27
 
31
28
 
32
29
        def __init__( self ):
33
 
                if the.repo is None:
34
 
                        raise RuntimeError( 'logic error: Deployment initialised when '
35
 
                                                                'the.repo is unset' )
36
30
                self.load_deployment_state()
37
31
                self.conflicts_checked = False
38
32
 
39
33
 
40
34
        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
 
                """
44
35
 
45
36
                # list of files that were copied-in (or at least given the opportunity
46
37
        # to be) and updated through the vcs update.  This means that, while
47
38
        # there may have been conflicts during the update (which will be dealt
48
 
        # with in the repo), any conflicts arising with these files in the home
49
 
        # directory are no longer important and can be ignored.  In short, this
50
 
        # is a list of files that it is safe to deploy, regardless of their
51
 
        # state in the home directory.
 
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.
52
44
                self.deploy_files = None
53
45
 
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
60
 
 
61
46
                # do we have a repo?
62
47
                if not os.path.exists( the.repo.full_dir ): return
63
48
 
64
49
                # if no file list exists, we're done
65
50
                file = os.path.join( the.full_mddir, "deploy.%s" % the.repo.name )
66
51
                if not os.path.isfile( file ):
 
52
                        if the.verbose: print "no deployment state found"
67
53
                        return
68
54
 
69
55
                # read the file list
70
 
                if the.verbose: print "deployment state found; loading"
 
56
                if the.verbose: print "loading deployment state"
71
57
                f = open( file, 'r' )
72
 
                state = json.loads( f.read() )
73
 
 
74
 
                # unpack deployment state
75
 
                self.deploy_files = state['deploy_files'];
76
 
                self.initial_revno = state['initial_revno'];
77
 
                self.affected_files = state['affected_files'];
 
58
                self.deploy_files = f.read().splitlines()
78
59
 
79
60
 
80
61
        def save_deployment_state( self ):
81
 
                """Save the current deployment state (so there will be a deployment ongoing).
82
 
                """
83
 
 
84
62
                if the.verbose: print "saving deployment state"
85
63
 
86
64
                # create metadata directory, as necessary
87
65
                if not os.path.isdir( the.full_mddir ):
88
66
                        os.mkdir( the.full_mddir )
89
67
 
90
 
                # pack deployment state
91
 
                state = {
92
 
                        'deploy_files': self.deploy_files,
93
 
                        'initial_revno': self.initial_revno,
94
 
                        'affected_files': self.affected_files,
95
 
                }
96
 
 
97
68
                # create file
98
69
                file = os.path.join( the.full_mddir, "deploy.%s" % the.repo.name )
99
70
                f = open( file, 'w' )
100
 
                f.write( json.dumps( state ) );
 
71
                f.write( '\n'.join( self.deploy_files ) + '\n' )
101
72
 
102
73
 
103
74
        def remove_deployment_state( self ):
104
 
                """Remove the current deployment state (so no deployment will be ongoing).
105
 
                """
106
75
 
107
 
                # check it exists
 
76
                # delete it, if it exists
108
77
                file = os.path.join( the.full_mddir, "deploy.%s" % the.repo.name )
109
78
                if( os.path.isfile( file ) ):
110
 
 
111
 
                        # delete it
112
 
                        if the.verbose: print "removing deployment state"
113
79
                        os.unlink( file )
114
80
 
115
81
 
116
82
        def is_ongoing( self ):
117
 
                """Is there a deployment currently ongoing?
118
 
                """
119
 
 
120
83
                return False if self.deploy_files is None else True
121
84
 
122
85
 
123
86
        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
 
 
128
87
                if( ongoing ):
129
88
                        if self.deploy_files is None:
130
89
                                raise self.DeploymentOngoing( False )
133
92
                                raise self.DeploymentOngoing( True )
134
93
 
135
94
 
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
 
                """
 
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 ) )
147
236
 
148
237
                # check we don't already have a file list
149
238
                self.check_ongoing( False )
150
239
 
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.deploy_files = list()
154
 
                else:
155
 
                        # copy in
156
 
                        if the.verbose: print "importing files..."
157
 
                        walker = CopyInWalker()
158
 
                        walker.walk()
159
 
                        if( walker.changed ):
160
 
                                raise self.CopyInConflicts( walker.changed )
161
 
                        self.deploy_files = walker.walk_list
162
 
 
163
 
                        # obtain initial revno
164
 
                        self.initial_revno = the.repo.vcs.get_revno()
 
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 )
165
250
 
166
251
                # save state
167
252
                self.save_deployment_state()
168
253
 
169
254
 
170
 
        def get_conflicts( self, affected_files = None ):
171
 
                """Check to see if there are any delpoyment conflicts.  If a list of affected
172
 
                files is supplied, then only those files are checked (and they are also
173
 
                saved with the deployment state).  Otherwise, all files in the
174
 
                repository are checked.
175
 
                """
176
 
 
177
 
                # check we have a file list
178
 
                self.check_ongoing( True )
179
 
 
180
 
                # set updated files
181
 
                if affected_files is not None:
182
 
                        self.affected_files = affected_files
183
 
                        self.save_deployment_state()
184
 
 
185
 
                # check for deployment conflicts
186
 
                walker = ConflictWalker( self.deploy_files, self.affected_files )
187
 
                walker.walk()
188
 
 
189
 
                self.conflicts_checked = True
190
 
                return walker.changed + walker.obstructed
191
 
 
192
 
 
193
255
        def copy_out( self ):
194
 
                """Copy-out changed files frmo the repository to the home directory.  If the
195
 
                deployment state incudes a list of affected files, then only those fiels
196
 
                are copied-out.
197
 
                """
 
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()
198
347
 
199
348
                # check we have a file list
200
349
                self.check_ongoing( True )
201
350
 
202
 
                # check that deployment conflicts have been checked-for
 
351
                # we should already have handled conflicts
203
352
                if not self.conflicts_checked:
204
 
                        raise RuntimeError('logic error: deployment conflicts unchecked' )
 
353
                        raise RuntimeError(
 
354
                                'logic error: conflicts should have been checked!' )
205
355
 
206
356
                # copy out
207
 
                if the.verbose: print "exporting files..."
208
 
                walker = CopyOutWalker( self.affected_files )
209
 
                walker.walk()
 
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 )
210
360
 
211
361
                # clear state
212
362
                self.remove_deployment_state()
213
363
 
214
364
 
 
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
 
215
418
        class DeploymentOngoing( the.program.FatalError ):
216
419
 
217
420
                def __init__( self, ongoing ):
221
424
                                self.msg = "there is no ongoing deployment"
222
425
 
223
426
 
224
 
        class CopyInConflicts( the.program.FatalError ):
 
427
        class Conflict( Exception ):
225
428
 
226
 
                def __init__( self, conflicts ):
227
 
                        self.conflicts = conflicts
 
429
                def __init__( self, message ):
 
430
                        self.msg = message