/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: 2022-06-27 15:47:18 UTC
  • Revision ID: tim@ed.am-20220627154718-coj4in7pqgl3c8lr
updated Makefile for previous commit

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
23
 
import the, util
 
22
import os, re, shutil, filecmp, json
 
23
from . 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
 
        # 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
 
46
                # to be) and updated through the vcs update.  This means that, while
 
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
                if 'imported_files' in state:
 
76
                        self.imported_files = state['imported_files'];
 
77
                if 'initial_revno' in state:
 
78
                        self.initial_revno = state['initial_revno'];
 
79
                if 'affected_files' in state:
 
80
                        self.affected_files = state['affected_files'];
59
81
 
60
82
 
61
83
        def save_deployment_state( self ):
62
 
                if the.verbose: print "saving deployment state"
 
84
                """Save the current deployment state (so there will be a deployment ongoing).
 
85
                """
 
86
 
 
87
                if the.verbose >= 1: print("saving deployment state")
63
88
 
64
89
                # create metadata directory, as necessary
65
90
                if not os.path.isdir( the.full_mddir ):
66
91
                        os.mkdir( the.full_mddir )
67
92
 
 
93
                # pack deployment state
 
94
                state = {
 
95
                        'imported_files': self.imported_files,
 
96
                        'initial_revno': self.initial_revno,
 
97
                        'affected_files': self.affected_files,
 
98
                }
 
99
 
68
100
                # create file
69
101
                file = os.path.join( the.full_mddir, "deploy.%s" % the.repo.name )
70
102
                f = open( file, 'w' )
71
 
                f.write( '\n'.join( self.deploy_files ) + '\n' )
 
103
                f.write( json.dumps( state ) );
72
104
 
73
105
 
74
106
        def remove_deployment_state( self ):
 
107
                """Remove the current deployment state (so no deployment will be ongoing).
 
108
                """
75
109
 
76
 
                # delete it, if it exists
 
110
                # check it exists
77
111
                file = os.path.join( the.full_mddir, "deploy.%s" % the.repo.name )
78
112
                if( os.path.isfile( file ) ):
 
113
 
 
114
                        # delete it
 
115
                        if the.verbose >= 1: print("removing deployment state")
79
116
                        os.unlink( file )
80
117
 
81
118
 
82
119
        def is_ongoing( self ):
83
 
                return False if self.deploy_files is None else True
 
120
                """Is there a deployment currently ongoing?
 
121
                """
 
122
 
 
123
                return False if self.imported_files is None else True
84
124
 
85
125
 
86
126
        def check_ongoing( self, ongoing = True ):
 
127
                """Check that a deployment either is or is not ongoing and raise an error if
 
128
                not.
 
129
                """
 
130
 
87
131
                if( ongoing ):
88
 
                        if self.deploy_files is None:
 
132
                        if self.imported_files is None:
89
133
                                raise self.DeploymentOngoing( False )
90
134
                else:
91
 
                        if self.deploy_files is not None:
 
135
                        if self.imported_files is not None:
92
136
                                raise self.DeploymentOngoing( True )
93
137
 
94
138
 
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 ) )
 
139
        def get_initial_revno( self ):
 
140
                """Get the initial revision identifier from the deployment state.
 
141
                """
 
142
 
 
143
                return self.initial_revno
 
144
 
 
145
 
 
146
        def copy_in( self, initial_revno = None ):
 
147
                """Copy-in changes from the home directory to the repository.  When finished,
 
148
                the state of deployment is saved, meaning that a deployment is ongoing.
 
149
                """
236
150
 
237
151
                # check we don't already have a file list
238
152
                self.check_ongoing( False )
239
153
 
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 )
 
154
                # if the repo doesn't exist, we have an empty file list
 
155
                if not os.path.exists( the.repo.full_dir ):
 
156
                        self.imported_files = list()
 
157
                else:
 
158
                        # copy in
 
159
                        if the.verbose >= 1: print("importing files...")
 
160
                        walker = CopyInWalker()
 
161
                        walker.walk()
 
162
                        self.imported_files = walker.walk_list
 
163
 
 
164
                        # obtain initial revno
 
165
                        self.initial_revno = the.repo.vcs.get_revno()
250
166
 
251
167
                # save state
252
168
                self.save_deployment_state()
253
169
 
254
170
 
255
 
        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()
347
 
 
348
 
                # check we have a file list
349
 
                self.check_ongoing( True )
350
 
 
351
 
                # we should already have handled conflicts
 
171
        def get_conflicts( self, affected_files = None ):
 
172
                """Check to see if there are any deployment conflicts.  If a list of affected
 
173
                files is supplied, then only those files are checked (and they are also
 
174
                saved with the deployment state).  Otherwise, all files in the
 
175
                repository are checked.
 
176
                """
 
177
 
 
178
                # check we have a file list
 
179
                self.check_ongoing( True )
 
180
 
 
181
                # set updated files
 
182
                if affected_files is not None:
 
183
                        self.affected_files = affected_files
 
184
                        self.save_deployment_state()
 
185
 
 
186
                # check for deployment conflicts
 
187
                walker = ConflictWalker( self.imported_files, self.affected_files )
 
188
                walker.walk()
 
189
 
 
190
                self.conflicts_checked = True
 
191
                return walker.changed + walker.obstructed
 
192
 
 
193
 
 
194
        def copy_out( self, quiet ):
 
195
                """Copy-out changed files from the repository to the home directory.  If the
 
196
                deployment state includes a list of affected files, then only those
 
197
                files are copied-out.
 
198
                """
 
199
 
 
200
                # check we have a file list
 
201
                self.check_ongoing( True )
 
202
 
 
203
                # check that deployment conflicts have been checked-for
352
204
                if not self.conflicts_checked:
353
 
                        raise RuntimeError(
354
 
                                'logic error: conflicts should have been checked!' )
 
205
                        raise RuntimeError('logic error: deployment conflicts unchecked' )
355
206
 
356
207
                # 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 )
 
208
                if the.verbose >= 1: print("exporting files...")
 
209
                walker = CopyOutWalker( self.affected_files, not quiet )
 
210
                walker.walk()
360
211
 
361
212
                # clear state
362
213
                self.remove_deployment_state()
363
214
 
364
215
 
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
216
        class DeploymentOngoing( the.program.FatalError ):
419
217
 
420
218
                def __init__( self, ongoing ):
422
220
                                self.msg = "there is an ongoing deployment"
423
221
                        else:
424
222
                                self.msg = "there is no ongoing deployment"
425
 
 
426
 
 
427
 
        class Conflict( Exception ):
428
 
 
429
 
                def __init__( self, message ):
430
 
                        self.msg = message