Skip to content

Commit f2fe28d

Browse files
committed
Start work on migration script for v5.0.0
1 parent 1173365 commit f2fe28d

File tree

1 file changed

+305
-0
lines changed

1 file changed

+305
-0
lines changed

helpers/migrate.py

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
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

Comments
 (0)