1
+ #!/usr/bin/env python3
2
+
3
+ # Copyright 2019 Google LLC
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ import argparse
18
+ import copy
19
+ import subprocess
20
+ import sys
21
+ import shutil
22
+ import re
23
+
24
+ MIGRATIONS = [
25
+ {
26
+ "resource_type" : "google_container_cluster" ,
27
+ "name" : "zonal_primary" ,
28
+ "rename" : "primary" ,
29
+ "replace" : True ,
30
+ "module" : ""
31
+ },
32
+ ]
33
+
34
+ class ModuleMigration :
35
+ """
36
+ Migrate the resources from a flat project factory to match the new
37
+ module structure created by the G Suite refactor.
38
+ """
39
+
40
+ def __init__ (self , source_module ):
41
+ self .source_module = source_module
42
+
43
+ def moves (self ):
44
+ """
45
+ Generate the set of old/new resource pairs that will be migrated
46
+ to the `destination` module.
47
+ """
48
+ resources = self .targets ()
49
+ moves = []
50
+ for (old , migration ) in resources :
51
+ new = copy .deepcopy (old )
52
+ new .module += migration ["module" ]
53
+
54
+ print ("new" , new .name )
55
+
56
+ # Update the copied resource with the "rename" value if it is set
57
+ if "rename" in migration :
58
+ new .name = migration ["rename" ]
59
+
60
+ pair = (old .path (), new .path ())
61
+ moves .append (pair )
62
+
63
+ print ("moves" , moves )
64
+ return moves
65
+
66
+ def targets (self ):
67
+ """
68
+ A list of resources that will be moved to the new module """
69
+ to_move = []
70
+
71
+ for migration in MIGRATIONS :
72
+ resource_type = migration ["resource_type" ]
73
+ resource_name = migration ["name" ]
74
+ matching_resources = self .source_module .get_resources (
75
+ resource_type ,
76
+ resource_name )
77
+ to_move += [(r , migration ) for r in matching_resources ]
78
+
79
+ return to_move
80
+
81
+ class TerraformModule :
82
+ """
83
+ A Terraform module with associated resources.
84
+ """
85
+
86
+ def __init__ (self , name , resources ):
87
+ """
88
+ Create a new module and associate it with a list of resources.
89
+ """
90
+ self .name = name
91
+ self .resources = resources
92
+
93
+ def get_resources (self , resource_type = None , resource_name = None ):
94
+ """
95
+ Return a list of resources matching the given resource type and name.
96
+ """
97
+
98
+ ret = []
99
+ for resource in self .resources :
100
+ matches_type = (resource_type is None or
101
+ resource_type == resource .resource_type )
102
+
103
+ name_pattern = re .compile (r'%s(\[\d+\])?' % resource_name )
104
+ matches_name = (resource_name is None or
105
+ name_pattern .match (resource .name ))
106
+
107
+ if matches_type and matches_name :
108
+ ret .append (resource )
109
+
110
+ return ret
111
+
112
+ def has_resource (self , resource_type = None , resource_name = None ):
113
+ """
114
+ Does this module contain a resource with the matching type and name?
115
+ """
116
+ for resource in self .resources :
117
+ matches_type = (resource_type is None or
118
+ resource_type == resource .resource_type )
119
+
120
+ matches_name = (resource_name is None or
121
+ resource_name in resource .name )
122
+
123
+ if matches_type and matches_name :
124
+ return True
125
+
126
+ return False
127
+
128
+ def __repr__ (self ):
129
+ return "{}({!r}, {!r})" .format (
130
+ self .__class__ .__name__ ,
131
+ self .name ,
132
+ [repr (resource ) for resource in self .resources ])
133
+
134
+
135
+ class TerraformResource :
136
+ """
137
+ A Terraform resource, defined by the the identifier of that resource.
138
+ """
139
+
140
+ @classmethod
141
+ def from_path (cls , path ):
142
+ """
143
+ Generate a new Terraform resource, based on the fully qualified
144
+ Terraform resource path.
145
+ """
146
+ if re .match (r'\A[\w.\[\]-]+\Z' , path ) is None :
147
+ raise ValueError (
148
+ "Invalid Terraform resource path {!r}" .format (path ))
149
+
150
+ parts = path .split ("." )
151
+ name = parts .pop ()
152
+ resource_type = parts .pop ()
153
+ module = "." .join (parts )
154
+ return cls (module , resource_type , name )
155
+
156
+ def __init__ (self , module , resource_type , name ):
157
+ """
158
+ Create a new TerraformResource from a pre-parsed path.
159
+ """
160
+ self .module = module
161
+ self .resource_type = resource_type
162
+
163
+ find_suffix = re .match ('(^.+)\[(\d+)\]' , name )
164
+ if find_suffix :
165
+ self .name = find_suffix .group (1 )
166
+ self .index = find_suffix .group (2 )
167
+ else :
168
+ self .name = name
169
+ self .index = - 1
170
+
171
+ def path (self ):
172
+ """
173
+ Return the fully qualified resource path.
174
+ """
175
+ parts = [self .module , self .resource_type , self .name ]
176
+ if parts [0 ] == '' :
177
+ del parts [0 ]
178
+ path = "." .join (parts )
179
+ if self .index is not - 1 :
180
+ path = "{0}[{1}]" .format (path , self .index )
181
+ return path
182
+
183
+ def __repr__ (self ):
184
+ return "{}({!r}, {!r}, {!r})" .format (
185
+ self .__class__ .__name__ ,
186
+ self .module ,
187
+ self .resource_type ,
188
+ self .name )
189
+
190
+ def group_by_module (resources ):
191
+ """
192
+ Group a set of resources according to their containing module.
193
+ """
194
+
195
+ groups = {}
196
+ for resource in resources :
197
+ if resource .module in groups :
198
+ groups [resource .module ].append (resource )
199
+ else :
200
+ groups [resource .module ] = [resource ]
201
+
202
+ return [
203
+ TerraformModule (name , contained )
204
+ for name , contained in groups .items ()
205
+ ]
206
+
207
+
208
+ def read_state (statefile ):
209
+ """
210
+ Read the terraform state at the given path.
211
+ """
212
+ argv = ["terraform" , "state" , "list" , "-state" , statefile ]
213
+ result = subprocess .run (argv ,
214
+ capture_output = True ,
215
+ check = True ,
216
+ encoding = 'utf-8' )
217
+ elements = result .stdout .split ("\n " )
218
+ elements .pop ()
219
+ return elements
220
+
221
+
222
+ def state_changes_for_module (module , statefile ):
223
+ """
224
+ Compute the Terraform state changes (deletions and moves) for a single
225
+ module.
226
+ """
227
+ commands = []
228
+
229
+ migration = ModuleMigration (module )
230
+
231
+ for (old , new ) in migration .moves ():
232
+ wrapper = '"{0}"'
233
+ argv = ["terraform" , "state" , "mv" , "-state" , statefile , wrapper .format (old ), wrapper .format (new )]
234
+ commands .append (argv )
235
+
236
+ return commands
237
+
238
+
239
+ def migrate (statefile , dryrun = False ):
240
+ """
241
+ Migrate the terraform state in `statefile` to match the post-refactor
242
+ resource structure.
243
+ """
244
+
245
+ # Generate a list of Terraform resource states from the output of
246
+ # `terraform state list`
247
+ resources = [
248
+ TerraformResource .from_path (path )
249
+ for path in read_state (statefile )
250
+ ]
251
+
252
+ # Group resources based on the module where they're defined.
253
+ modules = group_by_module (resources )
254
+ print ('modules' , len (modules ))
255
+
256
+ # Filter our list of Terraform modules down to anything that looks like a
257
+ # zonal GKE module. We key this off the presence off of
258
+ # `google_container_cluster.zonal_primary` since that should almost always be
259
+ # unique to a GKE module.
260
+ modules_to_migrate = [
261
+ module for module in modules
262
+ if module .has_resource ("google_container_cluster" , "zonal_primary" )
263
+ ]
264
+
265
+ print ("---- Migrating the following modules:" )
266
+ for module in modules_to_migrate :
267
+ print ("-- " + module .name )
268
+
269
+ # Collect a list of resources for each module
270
+ commands = []
271
+ for module in modules_to_migrate :
272
+ commands += state_changes_for_module (module , statefile )
273
+
274
+ for argv in commands :
275
+ if dryrun :
276
+ print (" " .join (argv ))
277
+ else :
278
+ subprocess .run (argv , check = True , encoding = 'utf-8' )
279
+
280
+ def main (argv ):
281
+ parser = argparser ()
282
+ args = parser .parse_args (argv [1 :])
283
+
284
+ print ("cp {} {}" .format (args .oldstate , args .newstate ))
285
+ shutil .copy (args .oldstate , args .newstate )
286
+
287
+ migrate (args .newstate , dryrun = args .dryrun )
288
+ print ("State migration complete, verify migration with "
289
+ "`terraform plan -state '{}'`" .format (args .newstate ))
290
+
291
+ def argparser ():
292
+ parser = argparse .ArgumentParser (description = 'Migrate Terraform state' )
293
+ parser .add_argument ('oldstate' , metavar = 'oldstate.json' ,
294
+ help = 'The current Terraform state (will not be '
295
+ 'modified)' )
296
+ parser .add_argument ('newstate' , metavar = 'newstate.json' ,
297
+ help = 'The path to the new state file' )
298
+ parser .add_argument ('--dryrun' , action = 'store_true' ,
299
+ help = 'Print the `terraform state mv` commands instead '
300
+ 'of running the commands.' )
301
+ return parser
302
+
303
+
304
+ if __name__ == "__main__" :
305
+ main (sys .argv )
0 commit comments