/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:49 UTC
  • Revision ID: tim@ed.am-20140212215149-msaxl7vo98il5i4a
added more commands

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# deployment.py
 
2
#
 
3
# Copyright (C) 2013 to 2014 Tim Marston <tim@edm.am>
 
4
#
 
5
# This file is part of stdhome (hereafter referred to as "this program").
 
6
# See http://ed.am/dev/stdhome for more information.
 
7
#
 
8
# This program is free software: you can redistribute it and/or modify
 
9
# it under the terms of the GNU General Public License as published by
 
10
# the Free Software Foundation, either version 3 of the License, or
 
11
# (at your option) any later version.
 
12
#
 
13
# This program is distributed in the hope that it will be useful,
 
14
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
15
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
16
# GNU General Public License for more details.
 
17
#
 
18
# You should have received a copy of the GNU General Public License
 
19
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
20
 
 
21
 
 
22
import os, re, shutil, filecmp
 
23
import the, util
 
24
 
 
25
 
 
26
class Deployment:
 
27
 
 
28
 
 
29
        def __init__( self ):
 
30
                self.load_deployment_state()
 
31
                self.conflicts_checked = False
 
32
 
 
33
 
 
34
        def load_deployment_state( self ):
 
35
 
 
36
                # 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
 
45
 
 
46
                # do we have a repo?
 
47
                if not os.path.exists( the.repo.full_dir ): return
 
48
 
 
49
                # if no file list exists, we're done
 
50
                file = os.path.join( the.full_mddir, "deploy.%s" % the.repo.name )
 
51
                if not os.path.isfile( file ):
 
52
                        if the.verbose: print "no deployment state found"
 
53
                        return
 
54
 
 
55
                # read the file list
 
56
                if the.verbose: print "loading deployment state"
 
57
                f = open( file, 'r' )
 
58
                self.deploy_files = f.read().splitlines()
 
59
 
 
60
 
 
61
        def save_deployment_state( self ):
 
62
                if the.verbose: print "saving deployment state"
 
63
 
 
64
                # create metadata directory, as necessary
 
65
                if not os.path.isdir( the.full_mddir ):
 
66
                        os.mkdir( the.full_mddir )
 
67
 
 
68
                # create file
 
69
                file = os.path.join( the.full_mddir, "deploy.%s" % the.repo.name )
 
70
                f = open( file, 'w' )
 
71
                f.write( '\n'.join( self.deploy_files ) + '\n' )
 
72
 
 
73
 
 
74
        def remove_deployment_state( self ):
 
75
 
 
76
                # delete it, if it exists
 
77
                file = os.path.join( the.full_mddir, "deploy.%s" % the.repo.name )
 
78
                if( os.path.isfile( file ) ):
 
79
                        os.unlink( file )
 
80
 
 
81
 
 
82
        def is_ongoing( self ):
 
83
                return False if self.deploy_files is None else True
 
84
 
 
85
 
 
86
        def check_ongoing( self, ongoing = True ):
 
87
                if( ongoing ):
 
88
                        if self.deploy_files is None:
 
89
                                raise self.DeploymentOngoing( False )
 
90
                else:
 
91
                        if self.deploy_files is not None:
 
92
                                raise self.DeploymentOngoing( True )
 
93
 
 
94
 
 
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 ) )
 
236
 
 
237
                # check we don't already have a file list
 
238
                self.check_ongoing( False )
 
239
 
 
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 )
 
250
 
 
251
                # save state
 
252
                self.save_deployment_state()
 
253
 
 
254
 
 
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
 
352
                if not self.conflicts_checked:
 
353
                        raise RuntimeError(
 
354
                                'logic error: conflicts should have been checked!' )
 
355
 
 
356
                # 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 )
 
360
 
 
361
                # clear state
 
362
                self.remove_deployment_state()
 
363
 
 
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
 
 
418
        class DeploymentOngoing( the.program.FatalError ):
 
419
 
 
420
                def __init__( self, ongoing ):
 
421
                        if( ongoing ):
 
422
                                self.msg = "there is an ongoing deployment"
 
423
                        else:
 
424
                                self.msg = "there is no ongoing deployment"
 
425
 
 
426
 
 
427
        class Conflict( Exception ):
 
428
 
 
429
                def __init__( self, message ):
 
430
                        self.msg = message