tools/genconfig/genconfig.py (211 lines of code) (raw):

#!/usr/bin/env python # # Copyright 2016 Google Inc. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Generates a Jinja template from a list of GCE resource URLs.""" from __future__ import print_function from copy import deepcopy import re from subprocess import check_output import sys import yaml # pylint: disable=line-too-long SELF_LINK_PATTERN = re.compile(r'.*/([^/]+/[^/]+)/projects/([^/]+)/(.+)/([^/]+)/(.*)$') COMPUTE_SELF_LINK_PATTERN = re.compile(r'projects/([^/]+)/(.+)/([^/]+)/(.*)$') def usage(): print(''.join(['Usage: ', sys.argv[0], ' <project> <resource URL file> [<output dir>]']), file=sys.stderr) print(' Default output dir is current directory.', file=sys.stderr) print(file=sys.stderr) print(' Will generate the following files:', file=sys.stderr) print(' - <output dir>/generated.jinja', file=sys.stderr) print(' - <output dir>/generated.jinja.schema', file=sys.stderr) print(' - <output dir>/config.yaml', file=sys.stderr) def get_config(urls, project): """Given a set of resource URLs, returns a DM config. The DM config will contain a resource for each URL provided, filled with the appropriate type and properties from the live resource. All instances of the specified project will be replaced with "{{ env['project'] }}". Args: urls: the list of resource URLs to process into config project: the project for the associated resources Returns: A valid DM config containing all resources from the URL list. Raises: Exception: if any URLs or resources are invalid. """ resources = [] for cmd in get_gcloud_cmds(urls, project): if not cmd: continue useShell = sys.platform == 'win32' props = check_output(cmd.split(), shell=useShell) resources.extend(get_resource_config(props, project, urls)) return {'resources': resources} def get_resource_config(pstr, project, urls): """Returns a list of DM resource configurations. The algorithm for this is: 1) Load resource properties into YAML 2) Move name into the resource name field 3) Map kind to DM type 4) Scrub properties of output-only fields from the API 5) Fill resource properties with scrubbed properties Args: pstr: the string blob of resource properties to be converted to resource config. project: the project of the associated resource, used for parameterization Returns: A list of valid DM configuration for this resource and any auxiliary resources. """ for url in urls: if url.startswith('projects'): url = "https://www.googleapis.com/compute/v1/" + url m = SELF_LINK_PATTERN.match(url) name = m.group(5) ref = "$(ref." + name + ".selfLink)" pstr = pstr.replace(url, ref) pstr = pstr.replace(project, "{{env['project']}}") props = yaml.load(pstr) return get_resource_config_from_dict(props) def get_resource_config_from_dict(props): """Helper for get_resource_config().""" check_field(props, 'name') check_field(props, 'kind') resources = [{ 'name': props['name'], 'type': get_type(props['kind'], props), 'properties': scrub_properties(props) }] if props['kind'] == 'compute#instanceGroupManager' and 'autoscaler' in props: resources.extend(get_resource_config_from_dict(props['autoscaler'])) return resources def scrub_properties(orig_props): """Scrubs fields in API object properties for use in DM properties. Scrubbed fields include: - output-only fields common for most resources - output-only fields specific to a particular resource type Args: orig_props: the resource properties that need to be scrubbed Returns: The final scrubbed resource properties. """ props = deepcopy(orig_props) # Scrub output-only and unnecessary fields that we know about. props.pop('name', None) props.pop('id', None) props.pop('creationTimestamp', None) props.pop('status', None) props.pop('selfLink', None) # Some fields are at multiple layers and need to be scrubbed recursively. scrub_sub_properties(props) # Scrub fields that some types return. scrub_type_specific_properties(props) # Location is always returned as a full resource URL, but only the name is # used on input. if 'zone' in props: props['zone'] = props['zone'].rsplit('/', 1)[1] if 'region' in props: props['region'] = props['region'].rsplit('/', 1)[1] return props def scrub_type_specific_properties(props): """Scrubs fields that are unique to certain types.""" # TargetPool specific stuff. props.pop('instances', None) # ForwardingRule specific stuff. props.pop('IPAddress', None) # IGM specific stuff. props.pop('currentActions', None) props.pop('instanceGroup', None) props.pop('autoscaler', None) # Instance specific stuff. props.pop('cpuPlatform', None) props.pop('labelFingerprint', None) # Clear all IP assignments from network interfaces. Especially in # accessConfigs, where it is assumed there is a static IP address with the # given IP if assigned. if 'networkInterfaces' in props: for i in props['networkInterfaces']: i.pop('networkIP', None) if 'accessConfigs' in i: for ac in i['accessConfigs']: # This currently cannot support user-provided static IP, only allows # for ephemeral. ac.pop('natIP', None) def scrub_sub_properties(props): """Scrubs certain fields that may exist at any level in properties.""" if isinstance(props, list): # May be list of objects, must go deeper. for p in props: scrub_sub_properties(p) if isinstance(props, dict): # Scrub properties on this set. props.pop('kind', None) props.pop('fingerprint', None) # Check any sub structures. for unused_k, p in props.iteritems(): scrub_sub_properties(p) def get_type(kind, props): """Converts API resource 'kind' to a DM type. Only supports compute right now. Args: kind: the API resource kind associated with this resource, e.g., 'compute#instance' Returns: The DM type for this resource, e.g., compute.v1.instance Raises: Exception: if the kind is invalid or not supported. """ parts = kind.split('#') if len(parts) != 2: raise Exception('Invalid resource kind: ' + kind) service = { 'compute': 'compute.v1' }.get(parts[0], None) if service is None: raise Exception('Unsupported resource kind: ' + kind) if parts[1] == 'instanceGroupManager' and 'region' in props: return service + '.' + 'regionInstanceGroupManager' return service + '.' + parts[1] def check_field(props, field): if field not in props: raise Exception(''.join(['Resource properties missing field "', field, '": ', yaml.dump(props, default_flow_style=False)])) def get_gcloud_cmds(urls, project): """Returns list of gcloud describe commands given list of resource URLs.""" return [get_describe_cmd(u.rstrip(), project) for u in urls] def get_describe_cmd(url, project): r"""Builds a gcloud describe command given a resource URL. gcloud command will look like: gcloud compute instances describe instance-name \ --zone us-central1-f --format yaml NOTE: only supports compute resources right now. Args: url: the URL for this resource. project: the project of this resource. Returns: The gcloud command to be used to describe the resource in YAML, or empty if no command. Raises: Exception: if URL is bad. """ m = SELF_LINK_PATTERN.match(url) if m: service = m.group(1) unused_project = m.group(2) location = m.group(3) collection = m.group(4) name = m.group(5) else: # May be a truncated selfLink, allowed for compute. m = COMPUTE_SELF_LINK_PATTERN.match(url) if not m: raise Exception('Resource URL must be selfLink: ' + url) # Assumed truncated selfLink is compute only. service = 'compute/v1' location = m.group(2) collection = m.group(3) name = m.group(4) if service != 'compute/v1': raise Exception(''.join(['!!! Found resource that is unsupported: ', url])) # Autoscalers have no associated gcloud command for describing. if collection == 'autoscalers': print(''.join(['!!! Found autoscaler resource ', name, ', will attempt to generate config from its associated ', 'instanceGroupManager (NOTE: you must include the ' 'associated instanceGroupManager in the resource list).']), file=sys.stderr) return '' return ' '.join(['gcloud compute', get_gcloud_command_group(collection), 'describe', name, get_location_flag(location, url, collection), '--format yaml', '--project', project]) def get_gcloud_command_group(collection): """Converts API collection to gcloud sub-command group. Most collections are a direct mapping, but some are multi-word or have custom sub-command groups and must be special-cased here. Args: collection: collection within the respective API for this resource. Returns: gcloud command group string for this resource's collection. """ return { 'backendServices': 'backend-services', 'backendBuckets': 'backend-buckets', 'firewalls': 'firewall-rules', 'forwardingRules': 'forwarding-rules', 'httpHealthChecks': 'http-health-checks', 'httpsHealthChecks': 'https-health-checks', 'instanceTemplates': 'instance-templates', 'instanceGroupManagers': 'instance-groups managed', 'targetHttpProxies': 'target-http-proxies', 'targetHttpsProxies': 'target-https-proxies', 'targetPools': 'target-pools', 'urlMaps': 'url-maps', 'healthChecks': 'health-checks', 'instanceGroups': 'instance-groups' }.get(collection, collection) def get_location_flag(location, url, collection): """Location flag for gcloud command based on location in URL.""" # Location will typically be 'global', 'zones/<zone>', or regions/<region>. # We will assume the presence of a slash denotes one of the latter two. parts = location.split('/') if len(parts) > 1: if parts[0] == 'zones': return ' '.join(['--zone', parts[1]]) elif parts[0] == 'regions': return ' '.join(['--region', parts[1]]) raise Exception('Invalid location "' + location + '" in URL: ' + url) if collection in ['backendServices','forwardingRules']: return ' --global' # No slash, assume global and so no location flag is needed. return '' def get_config_dot_yaml(): return { 'imports': [{ 'path': 'generated.jinja' }], 'resources': [{ 'name': 'generated', 'type': 'generated.jinja' }] } def get_generated_schema(): return { 'info': { 'author': 'Auto-generated template with schema', 'description': 'Enter description here.', 'title': 'Enter title here.' }, 'properties': {} } def main(argv): if len(argv) < 3: usage() sys.exit(1) output_dir = argv[3] if len(argv) == 4 else '.' urls = [] with open(argv[2]) as f: urls = [line.rstrip() for line in f] config = get_config(urls, argv[1]) # Write generated template. with open(output_dir + '/generated.jinja', 'w') as f: f.write(yaml.dump(config, default_flow_style=False)) with open(output_dir + '/generated.jinja.schema', 'w') as f: f.write(yaml.dump(get_generated_schema(), default_flow_style=False)) # Write yaml config which uses template. with open(output_dir + '/config.yaml', 'w') as f: f.write(yaml.dump(get_config_dot_yaml(), default_flow_style=False)) print(''.join(['All done! See files generated in output directory "', output_dir, '".']), file=sys.stderr) print(file=sys.stderr) print(''.join(['You may want to modify them to have different resource names,' , ' parameterized properties,' , ' or references between related resources.']), file=sys.stderr) if __name__ == '__main__': main(sys.argv)