diff --git a/VERSION b/VERSION index 8b31b2b4fdb..f802f1a2cdb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.0-0.3.beta1 +2.0.0-0.4.beta2 diff --git a/cloud/amazon/ec2.py b/cloud/amazon/ec2.py index 6572a9286f4..ed36b855480 100644 --- a/cloud/amazon/ec2.py +++ b/cloud/amazon/ec2.py @@ -1226,8 +1226,12 @@ def startstop_instances(module, ec2, instance_ids, state, instance_tags): wait = module.params.get('wait') wait_timeout = int(module.params.get('wait_timeout')) + source_dest_check = module.params.get('source_dest_check') + termination_protection = module.params.get('termination_protection') changed = False instance_dict_array = [] + source_dest_check = module.params.get('source_dest_check') + termination_protection = module.params.get('termination_protection') if not isinstance(instance_ids, list) or len(instance_ids) < 1: # Fail unless the user defined instance tags diff --git a/cloud/amazon/ec2_ami_find.py b/cloud/amazon/ec2_ami_find.py index f5ed91baab5..ce7506e0961 100644 --- a/cloud/amazon/ec2_ami_find.py +++ b/cloud/amazon/ec2_ami_find.py @@ -163,14 +163,133 @@ EXAMPLES = ''' wait: yes ''' +RETURN = ''' +ami_id: + description: id of found amazon image + returned: when AMI found + type: string + sample: "ami-e9095e8c" +architecture: + description: architecture of image + returned: when AMI found + type: string + sample: "x86_64" +architecture: + description: architecture of image + returned: when AMI found + type: string + sample: "x86_64" +block_device_mapping: + description: block device mapping associated with image + returned: when AMI found + type: dictionary of block devices + sample: "{ + '/dev/xvda': { + 'delete_on_termination': true, + 'encrypted': false, + 'size': 8, + 'snapshot_id': 'snap-ca0330b8', + 'volume_type': 'gp2' + }" +creationDate: + description: creation date of image + returned: when AMI found + type: string + sample: "2015-10-15T22:43:44.000Z" +description: + description: description of image + returned: when AMI found + type: string + sample: "test-server01" +hypervisor: + description: type of hypervisor + returned: when AMI found + type: string + sample: "xen" +is_public: + description: whether image is public + returned: when AMI found + type: bool + sample: false +location: + description: location of image + returned: when AMI found + type: string + sample: "435210894375/test-server01-20151015-234343" +name: + description: ami name of image + returned: when AMI found + type: string + sample: "test-server01-20151015-234343" +owner_id: + description: owner of image + returned: when AMI found + type: string + sample: "435210894375" +platform: + description: plaform of image + returned: when AMI found + type: string + sample: null +root_device_name: + description: rood device name of image + returned: when AMI found + type: string + sample: "/dev/xvda" +root_device_type: + description: rood device type of image + returned: when AMI found + type: string + sample: "ebs" +state: + description: state of image + returned: when AMI found + type: string + sample: "available" +tags: + description: tags assigned to image + returned: when AMI found + type: dictionary of tags + sample: "{ + 'Environment': 'devel', + 'Name': 'test-server01', + 'Role': 'web' + }" +virtualization_type: + description: image virtualization type + returned: when AMI found + type: string + sample: "hvm" +''' + try: import boto.ec2 + from boto.ec2.blockdevicemapping import BlockDeviceType, BlockDeviceMapping HAS_BOTO=True except ImportError: HAS_BOTO=False import json +def get_block_device_mapping(image): + """ + Retrieves block device mapping from AMI + """ + + bdm_dict = dict() + bdm = getattr(image,'block_device_mapping') + for device_name in bdm.keys(): + bdm_dict[device_name] = { + 'size': bdm[device_name].size, + 'snapshot_id': bdm[device_name].snapshot_id, + 'volume_type': bdm[device_name].volume_type, + 'encrypted': bdm[device_name].encrypted, + 'delete_on_termination': bdm[device_name].delete_on_termination + } + + return bdm_dict + + def main(): argument_spec = ec2_argument_spec() argument_spec.update(dict( @@ -255,8 +374,12 @@ def main(): data = { 'ami_id': image.id, 'architecture': image.architecture, + 'block_device_mapping': get_block_device_mapping(image), + 'creationDate': image.creationDate, 'description': image.description, + 'hypervisor': image.hypervisor, 'is_public': image.is_public, + 'location': image.location, 'name': image.name, 'owner_id': image.owner_id, 'platform': image.platform, @@ -299,4 +422,3 @@ from ansible.module_utils.ec2 import * if __name__ == '__main__': main() - diff --git a/cloud/amazon/ec2_asg.py b/cloud/amazon/ec2_asg.py index b00ed7c97db..f6e71b4baf8 100644 --- a/cloud/amazon/ec2_asg.py +++ b/cloud/amazon/ec2_asg.py @@ -255,9 +255,10 @@ def get_properties(autoscaling_group): properties['viable_instances'] = 0 properties['terminating_instances'] = 0 + instance_facts = {} + if autoscaling_group.instances: properties['instances'] = [i.instance_id for i in autoscaling_group.instances] - instance_facts = {} for i in autoscaling_group.instances: instance_facts[i.instance_id] = {'health_status': i.health_status, 'lifecycle_state': i.lifecycle_state, @@ -274,7 +275,7 @@ def get_properties(autoscaling_group): properties['terminating_instances'] += 1 if i.lifecycle_state == 'Pending': properties['pending_instances'] += 1 - properties['instance_facts'] = instance_facts + properties['instance_facts'] = instance_facts properties['load_balancers'] = autoscaling_group.load_balancers if getattr(autoscaling_group, "tags", None): diff --git a/cloud/amazon/ec2_elb.py b/cloud/amazon/ec2_elb.py index 8ce5e004226..9f333764a5d 100644 --- a/cloud/amazon/ec2_elb.py +++ b/cloud/amazon/ec2_elb.py @@ -82,7 +82,7 @@ pre_tasks: local_action: module: ec2_elb instance_id: "{{ ansible_ec2_instance_id }}" - state: 'absent' + state: absent roles: - myrole post_tasks: @@ -91,7 +91,7 @@ post_tasks: module: ec2_elb instance_id: "{{ ansible_ec2_instance_id }}" ec2_elbs: "{{ item }}" - state: 'present' + state: present with_items: ec2_elbs """ diff --git a/cloud/amazon/ec2_elb_lb.py b/cloud/amazon/ec2_elb_lb.py index 9e6ef2ce51a..954f06496ae 100644 --- a/cloud/amazon/ec2_elb_lb.py +++ b/cloud/amazon/ec2_elb_lb.py @@ -29,6 +29,7 @@ options: state: description: - Create or destroy the ELB + choices: ["present", "absent"] required: true name: description: @@ -69,6 +70,12 @@ options: - An associative array of health check configuration settings (see example) require: false default: None + access_logs: + description: + - An associative array of access logs configuration settings (see example) + require: false + default: None + version_added: "2.0" subnets: description: - A list of VPC subnets to use when creating ELB. Zones should be empty if using this. @@ -165,7 +172,7 @@ EXAMPLES = """ load_balancer_port: 80 instance_port: 80 -# Configure a health check +# Configure a health check and the access logs - local_action: module: ec2_elb_lb name: "test-please-delete" @@ -184,6 +191,10 @@ EXAMPLES = """ interval: 30 # seconds unhealthy_threshold: 2 healthy_threshold: 10 + access_logs: + interval: 5 # minutes (defaults to 60) + s3_location: "my-bucket" # This value is required if access_logs is set + s3_prefix: "logs" # Ensure ELB is gone - local_action: @@ -311,7 +322,8 @@ class ElbManager(object): zones=None, purge_zones=None, security_group_ids=None, health_check=None, subnets=None, purge_subnets=None, scheme="internet-facing", connection_draining_timeout=None, - idle_timeout=None, cross_az_load_balancing=None, + idle_timeout=None, + cross_az_load_balancing=None, access_logs=None, stickiness=None, region=None, **aws_connect_params): self.module = module @@ -328,6 +340,7 @@ class ElbManager(object): self.connection_draining_timeout = connection_draining_timeout self.idle_timeout = idle_timeout self.cross_az_load_balancing = cross_az_load_balancing + self.access_logs = access_logs self.stickiness = stickiness self.aws_connect_params = aws_connect_params @@ -358,6 +371,8 @@ class ElbManager(object): self._set_idle_timeout() if self._check_attribute_support('cross_zone_load_balancing'): self._set_cross_az_load_balancing() + if self._check_attribute_support('access_log'): + self._set_access_log() # add sitcky options self.select_stickiness_policy() @@ -707,6 +722,32 @@ class ElbManager(object): self.elb_conn.modify_lb_attribute(self.name, 'CrossZoneLoadBalancing', attributes.cross_zone_load_balancing.enabled) + def _set_access_log(self): + attributes = self.elb.get_attributes() + if self.access_logs: + if 's3_location' not in self.access_logs: + self.module.fail_json(msg='s3_location information required') + + access_logs_config = { + "enabled": True, + "s3_bucket_name": self.access_logs['s3_location'], + "s3_bucket_prefix": self.access_logs.get('s3_prefix', ''), + "emit_interval": self.access_logs.get('interval', 60), + } + + update_access_logs_config = False + for attr, desired_value in access_logs_config.iteritems(): + if getattr(attributes.access_log, attr) != desired_value: + setattr(attributes.access_log, attr, desired_value) + update_access_logs_config = True + if update_access_logs_config: + self.elb_conn.modify_lb_attribute(self.name, 'AccessLog', attributes.access_log) + self.changed = True + elif attributes.access_log.enabled: + attributes.access_log.enabled = False + self.changed = True + self.elb_conn.modify_lb_attribute(self.name, 'AccessLog', attributes.access_log) + def _set_connection_draining_timeout(self): attributes = self.elb.get_attributes() if self.connection_draining_timeout is not None: @@ -849,7 +890,8 @@ def main(): connection_draining_timeout={'default': None, 'required': False}, idle_timeout={'default': None, 'required': False}, cross_az_load_balancing={'default': None, 'required': False}, - stickiness={'default': None, 'required': False, 'type': 'dict'} + stickiness={'default': None, 'required': False, 'type': 'dict'}, + access_logs={'default': None, 'required': False, 'type': 'dict'} ) ) @@ -874,6 +916,7 @@ def main(): security_group_ids = module.params['security_group_ids'] security_group_names = module.params['security_group_names'] health_check = module.params['health_check'] + access_logs = module.params['access_logs'] subnets = module.params['subnets'] purge_subnets = module.params['purge_subnets'] scheme = module.params['scheme'] @@ -907,7 +950,8 @@ def main(): purge_zones, security_group_ids, health_check, subnets, purge_subnets, scheme, connection_draining_timeout, idle_timeout, - cross_az_load_balancing, stickiness, + cross_az_load_balancing, + access_logs, stickiness, region=region, **aws_connect_params) # check for unsupported attributes for this version of boto diff --git a/cloud/amazon/ec2_metric_alarm.py b/cloud/amazon/ec2_metric_alarm.py index cead47460fa..94f303212ae 100644 --- a/cloud/amazon/ec2_metric_alarm.py +++ b/cloud/amazon/ec2_metric_alarm.py @@ -259,7 +259,6 @@ def main(): insufficient_data_actions=dict(type='list'), ok_actions=dict(type='list'), state=dict(default='present', choices=['present', 'absent']), - region=dict(aliases=['aws_region', 'ec2_region']), ) ) @@ -271,10 +270,14 @@ def main(): state = module.params.get('state') region, ec2_url, aws_connect_params = get_aws_connection_info(module) - try: - connection = connect_to_aws(boto.ec2.cloudwatch, region, **aws_connect_params) - except (boto.exception.NoAuthHandlerFound, StandardError), e: - module.fail_json(msg=str(e)) + + if region: + try: + connection = connect_to_aws(boto.ec2.cloudwatch, region, **aws_connect_params) + except (boto.exception.NoAuthHandlerFound, StandardError), e: + module.fail_json(msg=str(e)) + else: + module.fail_json(msg="region must be specified") if state == 'present': create_metric_alarm(connection, module) diff --git a/cloud/amazon/ec2_vol.py b/cloud/amazon/ec2_vol.py index 11cfb6eaad1..aba121d8dd9 100644 --- a/cloud/amazon/ec2_vol.py +++ b/cloud/amazon/ec2_vol.py @@ -27,41 +27,35 @@ options: - instance ID if you wish to attach the volume. Since 1.9 you can set to None to detach. required: false default: null - aliases: [] name: description: - volume Name tag if you wish to attach an existing volume (requires instance) required: false default: null - aliases: [] version_added: "1.6" id: description: - volume id if you wish to attach an existing volume (requires instance) or remove an existing volume required: false default: null - aliases: [] version_added: "1.6" volume_size: description: - size of volume (in GB) to create. required: false default: null - aliases: [] volume_type: description: - Type of EBS volume; standard (magnetic), gp2 (SSD), io1 (Provisioned IOPS). "Standard" is the old EBS default and continues to remain the Ansible default for backwards compatibility. required: false default: standard - aliases: [] version_added: "1.9" iops: description: - the provisioned IOPs you want to associate with this volume (integer). required: false default: 100 - aliases: [] version_added: "1.3" encrypted: description: @@ -73,7 +67,6 @@ options: - device id to override device mapping. Assumes /dev/sdf for Linux/UNIX and /dev/xvdf for Windows. required: false default: null - aliases: [] zone: description: - zone in which to create the volume, if unset uses the zone the instance is in (if set) @@ -92,7 +85,6 @@ options: required: false default: "yes" choices: ["yes", "no"] - aliases: [] version_added: "1.5" state: description: @@ -118,7 +110,7 @@ EXAMPLES = ''' - ec2_vol: instance: XXXXXX volume_size: 5 - iops: 200 + iops: 100 device_name: sdd # Example using snapshot id @@ -189,6 +181,7 @@ from distutils.version import LooseVersion try: import boto.ec2 + from boto.exception import BotoServerError HAS_BOTO = True except ImportError: HAS_BOTO = False @@ -200,6 +193,11 @@ def get_volume(module, ec2): zone = module.params.get('zone') filters = {} volume_ids = None + + # If no name or id supplied, just try volume creation based on module parameters + if id is None and name is None: + return None + if zone: filters['availability_zone'] = zone if name: @@ -219,18 +217,20 @@ def get_volume(module, ec2): module.fail_json(msg=msg) else: return None + if len(vols) > 1: module.fail_json(msg="Found more than one volume in zone (if specified) with name: %s" % name) return vols[0] def get_volumes(module, ec2): + instance = module.params.get('instance') - if not instance: - module.fail_json(msg = "Instance must be specified to get volumes") - try: - vols = ec2.get_all_volumes(filters={'attachment.instance-id': instance}) + if not instance: + vols = ec2.get_all_volumes() + else: + vols = ec2.get_all_volumes(filters={'attachment.instance-id': instance}) except boto.exception.BotoServerError, e: module.fail_json(msg = "%s: %s" % (e.error_code, e.error_message)) return vols @@ -254,7 +254,9 @@ def boto_supports_volume_encryption(): """ return hasattr(boto, 'Version') and LooseVersion(boto.Version) >= LooseVersion('2.29.0') + def create_volume(module, ec2, zone): + changed = False name = module.params.get('name') id = module.params.get('id') instance = module.params.get('instance') @@ -267,30 +269,15 @@ def create_volume(module, ec2, zone): if iops: volume_type = 'io1' - if instance == 'None' or instance == '': - instance = None - volume = get_volume(module, ec2) - if volume: - if volume.attachment_state() is not None: - if instance is None: - return volume - adata = volume.attach_data - if adata.instance_id != instance: - module.fail_json(msg = "Volume %s is already attached to another instance: %s" - % (name or id, adata.instance_id)) - else: - module.exit_json(msg="Volume %s is already mapped on instance %s: %s" % - (name or id, adata.instance_id, adata.device), - volume_id=id, - device=adata.device, - changed=False) - else: + if volume is None: try: if boto_supports_volume_encryption(): volume = ec2.create_volume(volume_size, zone, snapshot, volume_type, iops, encrypted) + changed = True else: volume = ec2.create_volume(volume_size, zone, snapshot, volume_type, iops) + changed = True while volume.status != 'available': time.sleep(3) @@ -301,52 +288,89 @@ def create_volume(module, ec2, zone): except boto.exception.BotoServerError, e: module.fail_json(msg = "%s: %s" % (e.error_code, e.error_message)) - return volume + return volume, changed def attach_volume(module, ec2, volume, instance): + device_name = module.params.get('device_name') - - if device_name and instance: - try: - attach = volume.attach(instance.id, device_name) - while volume.attachment_state() != 'attached': - time.sleep(3) - volume.update() - except boto.exception.BotoServerError, e: - module.fail_json(msg = "%s: %s" % (e.error_code, e.error_message)) - + changed = False + # If device_name isn't set, make a choice based on best practices here: # http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/block-device-mapping-concepts.html - + # In future this needs to be more dynamic but combining block device mapping best practices # (bounds for devices, as above) with instance.block_device_mapping data would be tricky. For me ;) - + # Use password data attribute to tell whether the instance is Windows or Linux - if device_name is None and instance: + if device_name is None: try: if not ec2.get_password_data(instance.id): device_name = '/dev/sdf' - attach = volume.attach(instance.id, device_name) - while volume.attachment_state() != 'attached': - time.sleep(3) - volume.update() else: device_name = '/dev/xvdf' - attach = volume.attach(instance.id, device_name) - while volume.attachment_state() != 'attached': - time.sleep(3) - volume.update() + except boto.exception.BotoServerError, e: + module.fail_json(msg = "%s: %s" % (e.error_code, e.error_message)) + + if volume.attachment_state() is not None: + adata = volume.attach_data + if adata.instance_id != instance.id: + module.fail_json(msg = "Volume %s is already attached to another instance: %s" + % (volume.id, adata.instance_id)) + else: + try: + volume.attach(instance.id, device_name) + while volume.attachment_state() != 'attached': + time.sleep(3) + volume.update() + changed = True except boto.exception.BotoServerError, e: module.fail_json(msg = "%s: %s" % (e.error_code, e.error_message)) -def detach_volume(module, ec2): - vol = get_volume(module, ec2) - if not vol or vol.attachment_state() is None: - module.exit_json(changed=False) - else: - vol.detach() - module.exit_json(changed=True) + return volume, changed + +def detach_volume(module, ec2, volume): + + changed = False + + if volume.attachment_state() is not None: + adata = volume.attach_data + volume.detach() + while volume.attachment_state() is not None: + time.sleep(3) + volume.update() + changed = True + + return volume, changed + +def get_volume_info(volume, state): + + # If we're just listing volumes then do nothing, else get the latest update for the volume + if state != 'list': + volume.update() + + volume_info = {} + attachment = volume.attach_data + + volume_info = { + 'create_time': volume.create_time, + 'id': volume.id, + 'iops': volume.iops, + 'size': volume.size, + 'snapshot_id': volume.snapshot_id, + 'status': volume.status, + 'type': volume.type, + 'zone': volume.zone, + 'attachment_set': { + 'attach_time': attachment.attach_time, + 'device': attachment.device, + 'instance_id': attachment.instance_id, + 'status': attachment.status + }, + 'tags': volume.tags + } + + return volume_info def main(): argument_spec = ec2_argument_spec() @@ -380,11 +404,30 @@ def main(): zone = module.params.get('zone') snapshot = module.params.get('snapshot') state = module.params.get('state') - + + # Ensure we have the zone or can get the zone + if instance is None and zone is None and state == 'present': + module.fail_json(msg="You must specify either instance or zone") + + # Set volume detach flag if instance == 'None' or instance == '': instance = None + detach_vol_flag = True + else: + detach_vol_flag = False + + # Set changed flag + changed = False - ec2 = ec2_connect(module) + region, ec2_url, aws_connect_params = get_aws_connection_info(module) + + if region: + try: + ec2 = connect_to_aws(boto.ec2, region, **aws_connect_params) + except (boto.exception.NoAuthHandlerFound, StandardError), e: + module.fail_json(msg=str(e)) + else: + module.fail_json(msg="region must be specified") if state == 'list': returned_volumes = [] @@ -393,21 +436,7 @@ def main(): for v in vols: attachment = v.attach_data - returned_volumes.append({ - 'create_time': v.create_time, - 'id': v.id, - 'iops': v.iops, - 'size': v.size, - 'snapshot_id': v.snapshot_id, - 'status': v.status, - 'type': v.type, - 'zone': v.zone, - 'attachment_set': { - 'attach_time': attachment.attach_time, - 'device': attachment.device, - 'status': attachment.status - } - }) + returned_volumes.append(get_volume_info(v, state)) module.exit_json(changed=False, volumes=returned_volumes) @@ -418,8 +447,12 @@ def main(): # instance is specified but zone isn't. # Useful for playbooks chaining instance launch with volume create + attach and where the # zone doesn't matter to the user. + inst = None if instance: - reservation = ec2.get_all_instances(instance_ids=instance) + try: + reservation = ec2.get_all_instances(instance_ids=instance) + except BotoServerError as e: + module.fail_json(msg=e.message) inst = reservation[0].instances[0] zone = inst.placement @@ -438,17 +471,19 @@ def main(): if volume_size and (id or snapshot): module.fail_json(msg="Cannot specify volume_size together with id or snapshot") - - if state == 'absent': - delete_volume(module, ec2) - + if state == 'present': - volume = create_volume(module, ec2, zone) - if instance: - attach_volume(module, ec2, volume, inst) - else: - detach_volume(module, ec2) - module.exit_json(volume_id=volume.id, device=device_name, volume_type=volume.type) + volume, changed = create_volume(module, ec2, zone) + if detach_vol_flag: + volume, changed = detach_volume(module, ec2, volume) + elif inst is not None: + volume, changed = attach_volume(module, ec2, volume, inst) + + # Add device, volume_id and volume_type parameters separately to maintain backward compatability + volume_info = get_volume_info(volume, state) + module.exit_json(changed=changed, volume=volume_info, device=volume_info['attachment_set']['device'], volume_id=volume_info['id'], volume_type=volume_info['type']) + elif state == 'absent': + delete_volume(module, ec2) # import module snippets from ansible.module_utils.basic import * diff --git a/cloud/amazon/iam.py b/cloud/amazon/iam.py index 8864cb10a6f..c1cd79f9a3f 100644 --- a/cloud/amazon/iam.py +++ b/cloud/amazon/iam.py @@ -565,7 +565,10 @@ def main(): region, ec2_url, aws_connect_kwargs = get_aws_connection_info(module) try: - iam = boto.iam.connection.IAMConnection(**aws_connect_kwargs) + if region: + iam = boto.iam.connect_to_region(region, **aws_connect_kwargs) + else: + iam = boto.iam.connection.IAMConnection(**aws_connect_kwargs) except boto.exception.NoAuthHandlerFound, e: module.fail_json(msg=str(e)) diff --git a/cloud/amazon/iam_cert.py b/cloud/amazon/iam_cert.py index 0c36abef322..cc79d1cdc1c 100644 --- a/cloud/amazon/iam_cert.py +++ b/cloud/amazon/iam_cert.py @@ -107,6 +107,7 @@ import sys try: import boto import boto.iam + import boto.ec2 HAS_BOTO = True except ImportError: HAS_BOTO = False @@ -246,7 +247,10 @@ def main(): region, ec2_url, aws_connect_kwargs = get_aws_connection_info(module) try: - iam = boto.iam.connection.IAMConnection(**aws_connect_kwargs) + if region: + iam = boto.iam.connect_to_region(region, **aws_connect_kwargs) + else: + iam = boto.iam.connection.IAMConnection(**aws_connect_kwargs) except boto.exception.NoAuthHandlerFound, e: module.fail_json(msg=str(e)) diff --git a/cloud/amazon/iam_policy.py b/cloud/amazon/iam_policy.py index 0d2ed506457..c17e1278ddc 100644 --- a/cloud/amazon/iam_policy.py +++ b/cloud/amazon/iam_policy.py @@ -307,7 +307,10 @@ def main(): region, ec2_url, aws_connect_kwargs = get_aws_connection_info(module) try: - iam = boto.iam.connection.IAMConnection(**aws_connect_kwargs) + if region: + iam = boto.iam.connect_to_region(region, **aws_connect_kwargs) + else: + iam = boto.iam.connection.IAMConnection(**aws_connect_kwargs) except boto.exception.NoAuthHandlerFound, e: module.fail_json(msg=str(e)) diff --git a/cloud/amazon/rds.py b/cloud/amazon/rds.py index d509bdf4fa0..1eb4cc8ab1d 100644 --- a/cloud/amazon/rds.py +++ b/cloud/amazon/rds.py @@ -24,7 +24,7 @@ description: options: command: description: - - Specifies the action to take. + - Specifies the action to take. The 'reboot' option is available starting at version 2.0 required: true choices: [ 'create', 'replicate', 'delete', 'facts', 'modify' , 'promote', 'snapshot', 'reboot', 'restore' ] instance_name: diff --git a/cloud/amazon/s3.py b/cloud/amazon/s3.py index 550998915d4..fdeaafd58bd 100644 --- a/cloud/amazon/s3.py +++ b/cloud/amazon/s3.py @@ -35,7 +35,7 @@ options: default: null aliases: ['ec2_secret_key', 'secret_key'] bucket: - description: + description: - Bucket name. required: true default: null @@ -131,12 +131,12 @@ options: default: 0 version_added: "2.0" s3_url: - description: + description: - S3 URL endpoint for usage with Eucalypus, fakes3, etc. Otherwise assumes AWS default: null aliases: [ S3_URL ] src: - description: + description: - The source file path when performing a PUT operation. required: false default: null @@ -416,17 +416,11 @@ def main(): if acl not in CannedACLStrings: module.fail_json(msg='Unknown permission specified: %s' % str(acl)) - if overwrite not in ['always', 'never', 'different']: + if overwrite not in ['always', 'never', 'different']: if module.boolean(overwrite): overwrite = 'always' else: - overwrite='never' - - if overwrite not in ['always', 'never', 'different']: - if module.boolean(overwrite): - overwrite = 'always' - else: - overwrite='never' + overwrite = 'never' region, ec2_url, aws_connect_kwargs = get_aws_connection_info(module) diff --git a/cloud/azure/azure.py b/cloud/azure/azure.py index c4fa41a6eb1..01a6240cf87 100644 --- a/cloud/azure/azure.py +++ b/cloud/azure/azure.py @@ -249,22 +249,29 @@ AZURE_ROLE_SIZES = ['ExtraSmall', 'Standard_G4', 'Standard_G5'] +from distutils.version import LooseVersion + try: import azure as windows_azure - from azure import WindowsAzureError, WindowsAzureMissingResourceError + if hasattr(windows_azure, '__version__') and LooseVersion(windows_azure.__version__) <= "0.11.1": + from azure import WindowsAzureError as AzureException + from azure import WindowsAzureMissingResourceError as AzureMissingException + else: + from azure.common import AzureException as AzureException + from azure.common import AzureMissingResourceHttpError as AzureMissingException + from azure.servicemanagement import (ServiceManagementService, OSVirtualHardDisk, SSH, PublicKeys, PublicKey, LinuxConfigurationSet, ConfigurationSetInputEndpoints, ConfigurationSetInputEndpoint, Listener, WindowsConfigurationSet) + HAS_AZURE = True except ImportError: HAS_AZURE = False -from distutils.version import LooseVersion from types import MethodType import json - def _wait_for_completion(azure, promise, wait_timeout, msg): if not promise: return wait_timeout = time.time() + wait_timeout @@ -274,7 +281,7 @@ def _wait_for_completion(azure, promise, wait_timeout, msg): if operation_result.status == "Succeeded": return - raise WindowsAzureError('Timed out waiting for async operation ' + msg + ' "' + str(promise.request_id) + '" to complete.') + raise AzureException('Timed out waiting for async operation ' + msg + ' "' + str(promise.request_id) + '" to complete.') def _delete_disks_when_detached(azure, wait_timeout, disk_names): def _handle_timeout(signum, frame): @@ -289,7 +296,7 @@ def _delete_disks_when_detached(azure, wait_timeout, disk_names): if disk.attached_to is None: azure.delete_disk(disk.name, True) disk_names.remove(disk_name) - except WindowsAzureError, e: + except AzureException, e: module.fail_json(msg="failed to get or delete disk, error was: %s" % (disk_name, str(e))) finally: signal.alarm(0) @@ -347,13 +354,13 @@ def create_virtual_machine(module, azure): result = azure.create_hosted_service(service_name=name, label=name, location=location) _wait_for_completion(azure, result, wait_timeout, "create_hosted_service") changed = True - except WindowsAzureError, e: + except AzureException, e: module.fail_json(msg="failed to create the new service, error was: %s" % str(e)) try: # check to see if a vm with this name exists; if so, do nothing azure.get_role(name, name, name) - except WindowsAzureMissingResourceError: + except AzureMissingException: # vm does not exist; create it if os_type == 'linux': @@ -419,13 +426,13 @@ def create_virtual_machine(module, azure): virtual_network_name=virtual_network_name) _wait_for_completion(azure, result, wait_timeout, "create_virtual_machine_deployment") changed = True - except WindowsAzureError, e: + except AzureException, e: module.fail_json(msg="failed to create the new virtual machine, error was: %s" % str(e)) try: deployment = azure.get_deployment_by_name(service_name=name, deployment_name=name) return (changed, urlparse(deployment.url).hostname, deployment) - except WindowsAzureError, e: + except AzureException, e: module.fail_json(msg="failed to lookup the deployment information for %s, error was: %s" % (name, str(e))) @@ -453,9 +460,9 @@ def terminate_virtual_machine(module, azure): disk_names = [] try: deployment = azure.get_deployment_by_name(service_name=name, deployment_name=name) - except WindowsAzureMissingResourceError, e: + except AzureMissingException, e: pass # no such deployment or service - except WindowsAzureError, e: + except AzureException, e: module.fail_json(msg="failed to find the deployment, error was: %s" % str(e)) # Delete deployment @@ -468,13 +475,13 @@ def terminate_virtual_machine(module, azure): role_props = azure.get_role(name, deployment.name, role.role_name) if role_props.os_virtual_hard_disk.disk_name not in disk_names: disk_names.append(role_props.os_virtual_hard_disk.disk_name) - except WindowsAzureError, e: + except AzureException, e: module.fail_json(msg="failed to get the role %s, error was: %s" % (role.role_name, str(e))) try: result = azure.delete_deployment(name, deployment.name) _wait_for_completion(azure, result, wait_timeout, "delete_deployment") - except WindowsAzureError, e: + except AzureException, e: module.fail_json(msg="failed to delete the deployment %s, error was: %s" % (deployment.name, str(e))) # It's unclear when disks associated with terminated deployment get detatched. @@ -482,14 +489,14 @@ def terminate_virtual_machine(module, azure): # become detatched by polling the list of remaining disks and examining the state. try: _delete_disks_when_detached(azure, wait_timeout, disk_names) - except (WindowsAzureError, TimeoutError), e: + except (AzureException, TimeoutError), e: module.fail_json(msg=str(e)) try: # Now that the vm is deleted, remove the cloud service result = azure.delete_hosted_service(service_name=name) _wait_for_completion(azure, result, wait_timeout, "delete_hosted_service") - except WindowsAzureError, e: + except AzureException, e: module.fail_json(msg="failed to delete the service %s, error was: %s" % (name, str(e))) public_dns_name = urlparse(deployment.url).hostname @@ -545,7 +552,8 @@ def main(): subscription_id, management_cert_path = get_azure_creds(module) wait_timeout_redirects = int(module.params.get('wait_timeout_redirects')) - if LooseVersion(windows_azure.__version__) <= "0.8.0": + + if hasattr(windows_azure, '__version__') and LooseVersion(windows_azure.__version__) <= "0.8.0": # wrapper for handling redirects which the sdk <= 0.8.0 is not following azure = Wrapper(ServiceManagementService(subscription_id, management_cert_path), wait_timeout_redirects) else: @@ -597,7 +605,7 @@ class Wrapper(object): while wait_timeout > time.time(): try: return f() - except WindowsAzureError, e: + except AzureException, e: if not str(e).lower().find("temporary redirect") == -1: time.sleep(5) pass diff --git a/cloud/docker/docker.py b/cloud/docker/docker.py index 0ab564208ba..e038aeb0239 100644 --- a/cloud/docker/docker.py +++ b/cloud/docker/docker.py @@ -97,9 +97,12 @@ options: - You can specify a different logging driver for the container than for the daemon. "json-file" Default logging driver for Docker. Writes JSON messages to file. docker logs command is available only for this logging driver. - "none" disables any logging for the container. docker logs won't be available with this driver. + "none" disables any logging for the container. "syslog" Syslog logging driver for Docker. Writes log messages to syslog. docker logs command is not available for this logging driver. + "journald" Journald logging driver for Docker. Writes log messages to "journald". + "gelf" Graylog Extended Log Format (GELF) logging driver for Docker. Writes log messages to a GELF endpoint likeGraylog or Logstash. + "fluentd" Fluentd logging driver for Docker. Writes log messages to "fluentd" (forward input). If not defined explicitly, the Docker daemon's default ("json-file") will apply. Requires docker >= 1.6.0. required: false @@ -108,11 +111,14 @@ options: - json-file - none - syslog + - journald + - gelf + - fluentd version_added: "2.0" log_opt: description: - - Additional options to pass to the logging driver selected above. See Docker log-driver - documentation for more information (https://docs.docker.com/reference/logging/overview/). + - Additional options to pass to the logging driver selected above. See Docker `log-driver + ` documentation for more information. Requires docker >=1.7.0. required: false default: null @@ -1056,11 +1062,11 @@ class DockerManager(object): continue # EXPOSED PORTS - expected_exposed_ports = set((image['ContainerConfig']['ExposedPorts'] or {}).keys()) + expected_exposed_ports = set((image['ContainerConfig'].get('ExposedPorts') or {}).keys()) for p in (self.exposed_ports or []): expected_exposed_ports.add("/".join(p)) - actually_exposed_ports = set((container["Config"]["ExposedPorts"] or {}).keys()) + actually_exposed_ports = set((container["Config"].get("ExposedPorts") or {}).keys()) if actually_exposed_ports != expected_exposed_ports: self.reload_reasons.append('exposed_ports ({0} => {1})'.format(actually_exposed_ports, expected_exposed_ports)) @@ -1386,6 +1392,11 @@ class DockerManager(object): changes = list(self.client.pull(image, tag=tag, stream=True, **extra_params)) try: last = changes[-1] + # seems Docker 1.8 puts an empty dict at the end of the + # stream; catch that and get the previous instead + # https://github.com/ansible/ansible-modules-core/issues/2043 + if last.strip() == '{}': + last = changes[-2] except IndexError: last = '{}' status = json.loads(last).get('status', '') @@ -1662,7 +1673,7 @@ def main(): net = dict(default=None), pid = dict(default=None), insecure_registry = dict(default=False, type='bool'), - log_driver = dict(default=None, choices=['json-file', 'none', 'syslog']), + log_driver = dict(default=None, choices=['json-file', 'none', 'syslog', 'journald', 'gelf', 'fluentd']), log_opt = dict(default=None, type='dict'), cpu_set = dict(default=None), cap_add = dict(default=None, type='list'), diff --git a/cloud/openstack/keystone_user.py b/cloud/openstack/_keystone_user.py similarity index 99% rename from cloud/openstack/keystone_user.py rename to cloud/openstack/_keystone_user.py index babcc3cc569..48cc87b241a 100644 --- a/cloud/openstack/keystone_user.py +++ b/cloud/openstack/_keystone_user.py @@ -21,6 +21,7 @@ DOCUMENTATION = ''' --- module: keystone_user version_added: "1.2" +deprecated: Deprecated in 2.0. Use os_user instead short_description: Manage OpenStack Identity (keystone) users, tenants and roles description: - Manage users,tenants, roles from OpenStack. diff --git a/cloud/openstack/os_image.py b/cloud/openstack/os_image.py index 4687ce5e972..076ea806396 100644 --- a/cloud/openstack/os_image.py +++ b/cloud/openstack/os_image.py @@ -75,12 +75,12 @@ options: required: false default: None ramdisk: - descrption: + description: - The name of an existing ramdisk image that will be associated with this image required: false default: None kernel: - descrption: + description: - The name of an existing kernel image that will be associated with this image required: false default: None @@ -154,7 +154,8 @@ def main(): disk_format=module.params['disk_format'], container_format=module.params['container_format'], wait=module.params['wait'], - timeout=module.params['timeout'] + timeout=module.params['timeout'], + is_public=module.params['is_public'], ) changed = True if not module.params['wait']: diff --git a/cloud/openstack/os_image_facts.py b/cloud/openstack/os_image_facts.py index fa5678b50b7..a54537172eb 100644 --- a/cloud/openstack/os_image_facts.py +++ b/cloud/openstack/os_image_facts.py @@ -22,7 +22,6 @@ except ImportError: HAS_SHADE = False DOCUMENTATION = ''' ---- module: os_image_facts short_description: Retrieve facts about an image within OpenStack. version_added: "2.0" @@ -55,77 +54,79 @@ EXAMPLES = ''' ''' RETURN = ''' -This module registers image details in facts named: openstack_image. When -image is not found, openstack_image will be null. - -id: - description: Unique UUID. - returned: success - type: string -name: - description: Name given to the image. - returned: success - type: string -status: - description: Image status. - returned: success - type: string -created_at: - description: Image created at timestamp. - returned: success - type: string -deleted: - description: Image deleted flag. - returned: success - type: boolean -container_format: - description: Container format of the image. - returned: success - type: string -min_ram: - description: Min amount of RAM required for this image. - returned: success - type: int -disk_format: - description: Disk format of the image. - returned: success - type: string -updated_at: - description: Image updated at timestamp. - returned: success - type: string -properties: - description: Additional properties associated with the image. - returned: success - type: dict -min_disk: - description: Min amount of disk space required for this image. - returned: success - type: int -protected: - description: Image protected flag. - returned: success - type: boolean -checksum: - description: Checksum for the image. - returned: success - type: string -owner: - description: Owner for the image. - returned: success - type: string -is_public: - description: Is plubic flag of the image. - returned: success - type: boolean -deleted_at: - description: Image deleted at timestamp. - returned: success - type: string -size: - description: Size of the image. - returned: success - type: int +openstack_image: + description: has all the openstack facts about the image + returned: always, but can be null + type: complex + contains: + id: + description: Unique UUID. + returned: success + type: string + name: + description: Name given to the image. + returned: success + type: string + status: + description: Image status. + returned: success + type: string + created_at: + description: Image created at timestamp. + returned: success + type: string + deleted: + description: Image deleted flag. + returned: success + type: boolean + container_format: + description: Container format of the image. + returned: success + type: string + min_ram: + description: Min amount of RAM required for this image. + returned: success + type: int + disk_format: + description: Disk format of the image. + returned: success + type: string + updated_at: + description: Image updated at timestamp. + returned: success + type: string + properties: + description: Additional properties associated with the image. + returned: success + type: dict + min_disk: + description: Min amount of disk space required for this image. + returned: success + type: int + protected: + description: Image protected flag. + returned: success + type: boolean + checksum: + description: Checksum for the image. + returned: success + type: string + owner: + description: Owner for the image. + returned: success + type: string + is_public: + description: Is plubic flag of the image. + returned: success + type: boolean + deleted_at: + description: Image deleted at timestamp. + returned: success + type: string + size: + description: Size of the image. + returned: success + type: int ''' diff --git a/cloud/openstack/os_keypair.py b/cloud/openstack/os_keypair.py index f62cc51bf64..64ebd8c67be 100644 --- a/cloud/openstack/os_keypair.py +++ b/cloud/openstack/os_keypair.py @@ -146,10 +146,14 @@ def main(): " as offered. Delete key first." % name ) else: - module.exit_json(changed=False, key=keypair) + changed = False + else: + keypair = cloud.create_keypair(name, public_key) + changed = True - new_key = cloud.create_keypair(name, public_key) - module.exit_json(changed=True, key=new_key) + module.exit_json(changed=changed, + key=keypair, + id=keypair['id']) elif state == 'absent': if keypair: diff --git a/cloud/openstack/os_network.py b/cloud/openstack/os_network.py index bc41d3870f4..82863ebd6b5 100644 --- a/cloud/openstack/os_network.py +++ b/cloud/openstack/os_network.py @@ -146,7 +146,10 @@ def main(): if state == 'present': if not net: net = cloud.create_network(name, shared, admin_state_up, external) - module.exit_json(changed=False, network=net, id=net['id']) + changed = True + else: + changed = False + module.exit_json(changed=changed, network=net, id=net['id']) elif state == 'absent': if not net: diff --git a/cloud/openstack/os_networks_facts.py b/cloud/openstack/os_networks_facts.py index 56c30adb203..6ac8786463d 100644 --- a/cloud/openstack/os_networks_facts.py +++ b/cloud/openstack/os_networks_facts.py @@ -82,33 +82,35 @@ EXAMPLES = ''' ''' RETURN = ''' -This module registers network details in facts named: openstack_networks. If a -network name/id and or filter does not result in a network found, an empty -list is set in openstack_networks. -id: - description: Unique UUID. - returned: success - type: string -name: - description: Name given to the network. - returned: success - type: string -status: - description: Network status. - returned: success - type: string -subnets: - description: Subnet(s) included in this network. - returned: success - type: list of strings -tenant_id: - description: Tenant id associated with this network. - returned: success - type: string -shared: - description: Network shared flag. - returned: success - type: boolean +openstack_networks: + description: has all the openstack facts about the networks + returned: always, but can be null + type: complex + contains: + id: + description: Unique UUID. + returned: success + type: string + name: + description: Name given to the network. + returned: success + type: string + status: + description: Network status. + returned: success + type: string + subnets: + description: Subnet(s) included in this network. + returned: success + type: list of strings + tenant_id: + description: Tenant id associated with this network. + returned: success + type: string + shared: + description: Network shared flag. + returned: success + type: boolean ''' def main(): diff --git a/cloud/openstack/os_nova_flavor.py b/cloud/openstack/os_nova_flavor.py index 82b3a53aa3d..f7924030461 100644 --- a/cloud/openstack/os_nova_flavor.py +++ b/cloud/openstack/os_nova_flavor.py @@ -217,8 +217,13 @@ def main(): rxtx_factor=module.params['rxtx_factor'], is_public=module.params['is_public'] ) - module.exit_json(changed=True, flavor=flavor) - module.exit_json(changed=False, flavor=flavor) + changed=True + else: + changed=False + + module.exit_json(changed=changed, + flavor=flavor, + id=flavor['id']) elif state == 'absent': if flavor: diff --git a/cloud/openstack/os_port.py b/cloud/openstack/os_port.py new file mode 100644 index 00000000000..d218e938b10 --- /dev/null +++ b/cloud/openstack/os_port.py @@ -0,0 +1,392 @@ +#!/usr/bin/python + +# Copyright (c) 2015 Hewlett-Packard Development Company, L.P. +# +# This module is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This software is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this software. If not, see . + +try: + import shade + HAS_SHADE = True +except ImportError: + HAS_SHADE = False + + +DOCUMENTATION = ''' +--- +module: os_port +short_description: Add/Update/Delete ports from an OpenStack cloud. +extends_documentation_fragment: openstack +author: "Davide Agnello (@dagnello)" +version_added: "2.0" +description: + - Add, Update or Remove ports from an OpenStack cloud. A state=present, + will ensure the port is created or updated if required. +options: + network: + description: + - Network ID or name this port belongs to. + required: true + name: + description: + - Name that has to be given to the port. + required: false + default: None + fixed_ips: + description: + - Desired IP and/or subnet for this port. Subnet is referenced by + subnet_id and IP is referenced by ip_address. + required: false + default: None + admin_state_up: + description: + - Sets admin state. + required: false + default: None + mac_address: + description: + - MAC address of this port. + required: false + default: None + security_groups: + description: + - Security group(s) ID(s) or name(s) associated with the port (comma + separated string or YAML list) + required: false + default: None + no_security_groups: + description: + - Do not associate a security group with this port. + required: false + default: False + allowed_address_pairs: + description: + - "Allowed address pairs list. Allowed address pairs are supported with + dictionary structure. + e.g. allowed_address_pairs: + - ip_address: 10.1.0.12 + mac_address: ab:cd:ef:12:34:56 + - ip_address: ..." + required: false + default: None + extra_dhcp_opt: + description: + - "Extra dhcp options to be assigned to this port. Extra options are + supported with dictionary structure. + e.g. extra_dhcp_opt: + - opt_name: opt name1 + opt_value: value1 + - opt_name: ..." + required: false + default: None + device_owner: + description: + - The ID of the entity that uses this port. + required: false + default: None + device_id: + description: + - Device ID of device using this port. + required: false + default: None + state: + description: + - Should the resource be present or absent. + choices: [present, absent] + default: present +''' + +EXAMPLES = ''' +# Create a port +- os_port: + state: present + auth: + auth_url: https://region-b.geo-1.identity.hpcloudsvc.com:35357/v2.0/ + username: admin + password: admin + project_name: admin + name: port1 + network: foo + +# Create a port with a static IP +- os_port: + state: present + auth: + auth_url: https://region-b.geo-1.identity.hpcloudsvc.com:35357/v2.0/ + username: admin + password: admin + project_name: admin + name: port1 + network: foo + fixed_ips: + - ip_address: 10.1.0.21 + +# Create a port with No security groups +- os_port: + state: present + auth: + auth_url: https://region-b.geo-1.identity.hpcloudsvc.com:35357/v2.0/ + username: admin + password: admin + project_name: admin + name: port1 + network: foo + no_security_groups: True + +# Update the existing 'port1' port with multiple security groups (version 1) +- os_port: + state: present + auth: + auth_url: https://region-b.geo-1.identity.hpcloudsvc.com:35357/v2.0/d + username: admin + password: admin + project_name: admin + name: port1 + security_groups: 1496e8c7-4918-482a-9172-f4f00fc4a3a5,057d4bdf-6d4d-472... + +# Update the existing 'port1' port with multiple security groups (version 2) +- os_port: + state: present + auth: + auth_url: https://region-b.geo-1.identity.hpcloudsvc.com:35357/v2.0/d + username: admin + password: admin + project_name: admin + name: port1 + security_groups: + - 1496e8c7-4918-482a-9172-f4f00fc4a3a5 + - 057d4bdf-6d4d-472... +''' + +RETURN = ''' +id: + description: Unique UUID. + returned: success + type: string +name: + description: Name given to the port. + returned: success + type: string +network_id: + description: Network ID this port belongs in. + returned: success + type: string +security_groups: + description: Security group(s) associated with this port. + returned: success + type: list of strings +status: + description: Port's status. + returned: success + type: string +fixed_ips: + description: Fixed ip(s) associated with this port. + returned: success + type: list of dicts +tenant_id: + description: Tenant id associated with this port. + returned: success + type: string +allowed_address_pairs: + description: Allowed address pairs with this port. + returned: success + type: list of dicts +admin_state_up: + description: Admin state up flag for this port. + returned: success + type: bool +''' + + +def _needs_update(module, port, cloud): + """Check for differences in the updatable values. + + NOTE: We don't currently allow name updates. + """ + compare_simple = ['admin_state_up', + 'mac_address', + 'device_owner', + 'device_id'] + compare_dict = ['allowed_address_pairs', + 'extra_dhcp_opt'] + compare_list = ['security_groups'] + + for key in compare_simple: + if module.params[key] is not None and module.params[key] != port[key]: + return True + for key in compare_dict: + if module.params[key] is not None and cmp(module.params[key], + port[key]) != 0: + return True + for key in compare_list: + if module.params[key] is not None and (set(module.params[key]) != + set(port[key])): + return True + + # NOTE: if port was created or updated with 'no_security_groups=True', + # subsequent updates without 'no_security_groups' flag or + # 'no_security_groups=False' and no specified 'security_groups', will not + # result in an update to the port where the default security group is + # applied. + if module.params['no_security_groups'] and port['security_groups'] != []: + return True + + if module.params['fixed_ips'] is not None: + for item in module.params['fixed_ips']: + if 'ip_address' in item: + # if ip_address in request does not match any in existing port, + # update is required. + if not any(match['ip_address'] == item['ip_address'] + for match in port['fixed_ips']): + return True + if 'subnet_id' in item: + return True + for item in port['fixed_ips']: + # if ip_address in existing port does not match any in request, + # update is required. + if not any(match.get('ip_address') == item['ip_address'] + for match in module.params['fixed_ips']): + return True + + return False + + +def _system_state_change(module, port, cloud): + state = module.params['state'] + if state == 'present': + if not port: + return True + return _needs_update(module, port, cloud) + if state == 'absent' and port: + return True + return False + + +def _compose_port_args(module, cloud): + port_kwargs = {} + optional_parameters = ['name', + 'fixed_ips', + 'admin_state_up', + 'mac_address', + 'security_groups', + 'allowed_address_pairs', + 'extra_dhcp_opt', + 'device_owner', + 'device_id'] + for optional_param in optional_parameters: + if module.params[optional_param] is not None: + port_kwargs[optional_param] = module.params[optional_param] + + if module.params['no_security_groups']: + port_kwargs['security_groups'] = [] + + return port_kwargs + + +def get_security_group_id(module, cloud, security_group_name_or_id): + security_group = cloud.get_security_group(security_group_name_or_id) + if not security_group: + module.fail_json(msg="Security group: %s, was not found" + % security_group_name_or_id) + return security_group['id'] + + +def main(): + argument_spec = openstack_full_argument_spec( + network=dict(required=False), + name=dict(required=False), + fixed_ips=dict(default=None), + admin_state_up=dict(default=None), + mac_address=dict(default=None), + security_groups=dict(default=None, type='list'), + no_security_groups=dict(default=False, type='bool'), + allowed_address_pairs=dict(default=None), + extra_dhcp_opt=dict(default=None), + device_owner=dict(default=None), + device_id=dict(default=None), + state=dict(default='present', choices=['absent', 'present']), + ) + + module_kwargs = openstack_module_kwargs( + mutually_exclusive=[ + ['no_security_groups', 'security_groups'], + ] + ) + + module = AnsibleModule(argument_spec, + supports_check_mode=True, + **module_kwargs) + + if not HAS_SHADE: + module.fail_json(msg='shade is required for this module') + name = module.params['name'] + state = module.params['state'] + + try: + cloud = shade.openstack_cloud(**module.params) + if module.params['security_groups']: + # translate security_groups to UUID's if names where provided + module.params['security_groups'] = [ + get_security_group_id(module, cloud, v) + for v in module.params['security_groups'] + ] + + port = None + network_id = None + if name: + port = cloud.get_port(name) + + if module.check_mode: + module.exit_json(changed=_system_state_change(module, port, cloud)) + + changed = False + if state == 'present': + if not port: + network = module.params['network'] + if not network: + module.fail_json( + msg="Parameter 'network' is required in Port Create" + ) + port_kwargs = _compose_port_args(module, cloud) + network_object = cloud.get_network(network) + + if network_object: + network_id = network_object['id'] + else: + module.fail_json( + msg="Specified network was not found." + ) + + port = cloud.create_port(network_id, **port_kwargs) + changed = True + else: + if _needs_update(module, port, cloud): + port_kwargs = _compose_port_args(module, cloud) + port = cloud.update_port(port['id'], **port_kwargs) + changed = True + module.exit_json(changed=changed, id=port['id'], port=port) + + if state == 'absent': + if port: + cloud.delete_port(port['id']) + changed = True + module.exit_json(changed=changed) + + except shade.OpenStackCloudException as e: + module.fail_json(msg=e.message) + +# this is magic, see lib/ansible/module_common.py +from ansible.module_utils.basic import * +from ansible.module_utils.openstack import * +if __name__ == '__main__': + main() diff --git a/cloud/openstack/os_router.py b/cloud/openstack/os_router.py index 3d4218d2d14..d48ed0417f1 100644 --- a/cloud/openstack/os_router.py +++ b/cloud/openstack/os_router.py @@ -58,12 +58,17 @@ options: required: true when I(interfaces) or I(enable_snat) are provided, false otherwise. default: None + external_fixed_ips: + description: + - The IP address parameters for the external gateway network. Each + is a dictionary with the subnet name or ID (subnet) and the IP + address to assign on the subnet (ip). If no IP is specified, + one is automatically assigned from that subnet. + required: false + default: None interfaces: description: - - List of subnets to attach to the router. Each is a dictionary with - the subnet name or ID (subnet) and the IP address to assign on that - subnet (ip). If no IP is specified, one is automatically assigned from - that subnet. + - List of subnets to attach to the router internal interface. required: false default: None requirements: ["shade"] @@ -76,28 +81,32 @@ EXAMPLES = ''' state: present name: simple_router -# Creates a router attached to ext_network1 and one subnet interface. -# An IP address from subnet1's IP range will automatically be assigned -# to that interface. +# Creates a router attached to ext_network1 on an IPv4 subnet and one +# internal subnet interface. - os_router: cloud: mycloud state: present name: router1 network: ext_network1 + external_fixed_ips: + - subnet: public-subnet + ip: 172.24.4.2 interfaces: - - subnet: subnet1 + - private-subnet -# Update existing router1 to include subnet2 (10.5.5.0/24), specifying -# the IP address within subnet2's IP range we'd like for that interface. +# Update existing router1 external gateway to include the IPv6 subnet. +# Note that since 'interfaces' is not provided, any existing internal +# interfaces on an existing router will be left intact. - os_router: cloud: mycloud state: present name: router1 network: ext_network1 - interfaces: - - subnet: subnet1 - - subnet: subnet2 - ip: 10.5.5.1 + external_fixed_ips: + - subnet: public-subnet + ip: 172.24.4.2 + - subnet: ipv6-public-subnet + ip: 2001:db8::3 # Delete router1 - os_router: @@ -150,44 +159,57 @@ router: ''' -def _needs_update(cloud, module, router, network): +def _needs_update(cloud, module, router, network, internal_subnet_ids): """Decide if the given router needs an update. """ if router['admin_state_up'] != module.params['admin_state_up']: return True - if router['external_gateway_info']['enable_snat'] != module.params['enable_snat']: - return True + if router['external_gateway_info']: + if router['external_gateway_info'].get('enable_snat', True) != module.params['enable_snat']: + return True if network: - if router['external_gateway_info']['network_id'] != network['id']: + if not router['external_gateway_info']: + return True + elif router['external_gateway_info']['network_id'] != network['id']: return True - # check subnet interfaces - for new_iface in module.params['interfaces']: - subnet = cloud.get_subnet(new_iface['subnet']) - if not subnet: - module.fail_json(msg='subnet %s not found' % new_iface['subnet']) - exists = False + # check external interfaces + if module.params['external_fixed_ips']: + for new_iface in module.params['external_fixed_ips']: + subnet = cloud.get_subnet(new_iface['subnet']) + exists = False - # compare the requested interface with existing, looking for an existing match - for existing_iface in router['external_gateway_info']['external_fixed_ips']: - if existing_iface['subnet_id'] == subnet['id']: - if 'ip' in new_iface: - if existing_iface['ip_address'] == new_iface['ip']: - # both subnet id and ip address match + # compare the requested interface with existing, looking for an existing match + for existing_iface in router['external_gateway_info']['external_fixed_ips']: + if existing_iface['subnet_id'] == subnet['id']: + if 'ip' in new_iface: + if existing_iface['ip_address'] == new_iface['ip']: + # both subnet id and ip address match + exists = True + break + else: + # only the subnet was given, so ip doesn't matter exists = True break - else: - # only the subnet was given, so ip doesn't matter - exists = True - break - # this interface isn't present on the existing router - if not exists: + # this interface isn't present on the existing router + if not exists: + return True + + # check internal interfaces + if module.params['interfaces']: + existing_subnet_ids = [] + for port in cloud.list_router_interfaces(router, 'internal'): + if 'fixed_ips' in port: + for fixed_ip in port['fixed_ips']: + existing_subnet_ids.append(fixed_ip['subnet_id']) + + if set(internal_subnet_ids) != set(existing_subnet_ids): return True return False -def _system_state_change(cloud, module, router, network): +def _system_state_change(cloud, module, router, network, internal_ids): """Check if the system state would be changed.""" state = module.params['state'] if state == 'absent' and router: @@ -195,7 +217,7 @@ def _system_state_change(cloud, module, router, network): if state == 'present': if not router: return True - return _needs_update(cloud, module, router, network) + return _needs_update(cloud, module, router, network, internal_ids) return False def _build_kwargs(cloud, module, router, network): @@ -213,12 +235,10 @@ def _build_kwargs(cloud, module, router, network): # can't send enable_snat unless we have a network kwargs['enable_snat'] = module.params['enable_snat'] - if module.params['interfaces']: + if module.params['external_fixed_ips']: kwargs['ext_fixed_ips'] = [] - for iface in module.params['interfaces']: + for iface in module.params['external_fixed_ips']: subnet = cloud.get_subnet(iface['subnet']) - if not subnet: - module.fail_json(msg='subnet %s not found' % iface['subnet']) d = {'subnet_id': subnet['id']} if 'ip' in iface: d['ip_address'] = iface['ip'] @@ -226,6 +246,25 @@ def _build_kwargs(cloud, module, router, network): return kwargs +def _validate_subnets(module, cloud): + external_subnet_ids = [] + internal_subnet_ids = [] + if module.params['external_fixed_ips']: + for iface in module.params['external_fixed_ips']: + subnet = cloud.get_subnet(iface['subnet']) + if not subnet: + module.fail_json(msg='subnet %s not found' % iface['subnet']) + external_subnet_ids.append(subnet['id']) + + if module.params['interfaces']: + for iface in module.params['interfaces']: + subnet = cloud.get_subnet(iface) + if not subnet: + module.fail_json(msg='subnet %s not found' % iface) + internal_subnet_ids.append(subnet['id']) + + return (external_subnet_ids, internal_subnet_ids) + def main(): argument_spec = openstack_full_argument_spec( state=dict(default='present', choices=['absent', 'present']), @@ -233,7 +272,8 @@ def main(): admin_state_up=dict(type='bool', default=True), enable_snat=dict(type='bool', default=True), network=dict(default=None), - interfaces=dict(type='list', default=None) + interfaces=dict(type='list', default=None), + external_fixed_ips=dict(type='list', default=None), ) module_kwargs = openstack_module_kwargs() @@ -248,8 +288,8 @@ def main(): name = module.params['name'] network = module.params['network'] - if module.params['interfaces'] and not network: - module.fail_json(msg='network is required when supplying interfaces') + if module.params['external_fixed_ips'] and not network: + module.fail_json(msg='network is required when supplying external_fixed_ips') try: cloud = shade.openstack_cloud(**module.params) @@ -261,9 +301,13 @@ def main(): if not net: module.fail_json(msg='network %s not found' % network) + # Validate and cache the subnet IDs so we can avoid duplicate checks + # and expensive API calls. + external_ids, internal_ids = _validate_subnets(module, cloud) + if module.check_mode: module.exit_json( - changed=_system_state_change(cloud, module, router, net) + changed=_system_state_change(cloud, module, router, net, internal_ids) ) if state == 'present': @@ -272,19 +316,38 @@ def main(): if not router: kwargs = _build_kwargs(cloud, module, router, net) router = cloud.create_router(**kwargs) + for internal_subnet_id in internal_ids: + cloud.add_router_interface(router, subnet_id=internal_subnet_id) changed = True else: - if _needs_update(cloud, module, router, net): + if _needs_update(cloud, module, router, net, internal_ids): kwargs = _build_kwargs(cloud, module, router, net) router = cloud.update_router(**kwargs) + + # On a router update, if any internal interfaces were supplied, + # just detach all existing internal interfaces and attach the new. + if internal_ids: + ports = cloud.list_router_interfaces(router, 'internal') + for port in ports: + cloud.remove_router_interface(router, port_id=port['id']) + for internal_subnet_id in internal_ids: + cloud.add_router_interface(router, subnet_id=internal_subnet_id) + changed = True - module.exit_json(changed=changed, router=router) + module.exit_json(changed=changed, + router=router, + id=router['id']) elif state == 'absent': if not router: module.exit_json(changed=False) else: + # We need to detach all internal interfaces on a router before + # we will be allowed to delete it. + ports = cloud.list_router_interfaces(router, 'internal') + for port in ports: + cloud.remove_router_interface(router, port_id=port['id']) cloud.delete_router(name) module.exit_json(changed=True) diff --git a/cloud/openstack/os_security_group.py b/cloud/openstack/os_security_group.py index e42b7f938f5..fd839755144 100644 --- a/cloud/openstack/os_security_group.py +++ b/cloud/openstack/os_security_group.py @@ -91,7 +91,7 @@ def _system_state_change(module, secgroup): def main(): argument_spec = openstack_full_argument_spec( name=dict(required=True), - description=dict(default=None), + description=dict(default=''), state=dict(default='present', choices=['absent', 'present']), ) diff --git a/cloud/openstack/os_server.py b/cloud/openstack/os_server.py index 74a5009f972..189840e2498 100644 --- a/cloud/openstack/os_server.py +++ b/cloud/openstack/os_server.py @@ -76,7 +76,8 @@ options: default: None security_groups: description: - - The name of the security group to which the instance should be added + - Names of the security groups to which the instance should be + added. This may be a YAML list or a common separated string. required: false default: None nics: @@ -84,20 +85,16 @@ options: - A list of networks to which the instance's interface should be attached. Networks may be referenced by net-id/net-name/port-id or port-name. - Also this accepts a string containing a list of net-id/port-id. - Eg: nics: "net-id=uuid-1,net-id=uuid-2" + - 'Also this accepts a string containing a list of (net/port)-(id/name) + Eg: nics: "net-id=uuid-1,port-name=myport"' required: false default: None - public_ip: + auto_ip: description: - Ensure instance has public ip however the cloud wants to do that required: false default: 'yes' - auto_floating_ip: - description: - - If the module should automatically assign a floating IP - required: false - default: 'yes' + aliases: ['auto_floating_ip', 'public_ip'] floating_ips: description: - list of valid floating IPs that pre-exist to assign to this node @@ -110,8 +107,9 @@ options: default: None meta: description: - - A list of key value pairs that should be provided as a metadata to - the new instance. + - 'A list of key value pairs that should be provided as a metadata to + the new instance or a string containing a list of key-value pairs. + Eg: meta: "key1=value1,key2=value2"' required: false default: None wait: @@ -197,7 +195,7 @@ EXAMPLES = ''' timeout: 200 flavor: 101 security_groups: default - auto_floating_ip: yes + auto_ip: yes # Creates a new instance in named cloud mordred availability zone az2 # and assigns a pre-known floating IP @@ -263,6 +261,25 @@ EXAMPLES = ''' timeout: 200 flavor: 4 nics: "net-id=4cb08b20-62fe-11e5-9d70-feff819cdc9f,net-id=542f0430-62fe-11e5-9d70-feff819cdc9f..." + +# Creates a new instance and attaches to a network and passes metadata to +# the instance +- os_server: + state: present + auth: + auth_url: https://region-b.geo-1.identity.hpcloudsvc.com:35357/v2.0/ + username: admin + password: admin + project_name: admin + name: vm1 + image: 4f905f38-e52a-43d2-b6ec-754a13ffb529 + key_name: ansible_key + timeout: 200 + flavor: 4 + nics: + - net-id: 34605f38-e52a-25d2-b6ec-754a13ffb723 + - net-name: another_network + meta: "hostname=test1,group=uge_master" ''' @@ -272,35 +289,37 @@ def _exit_hostvars(module, cloud, server, changed=True): changed=changed, server=server, id=server.id, openstack=hostvars) +def _parse_nics(nics): + for net in nics: + if type(net) == str: + for nic in net.split(','): + yield dict((nic.split('='),)) + else: + yield net + def _network_args(module, cloud): args = [] nics = module.params['nics'] - if type(nics) == str : - for kv_str in nics.split(","): - nic = {} - k, v = kv_str.split("=") - nic[k] = v - args.append(nic) - else: - for net in module.params['nics']: - if net.get('net-id'): - args.append(net) - elif net.get('net-name'): - by_name = cloud.get_network(net['net-name']) - if not by_name: - module.fail_json( - msg='Could not find network by net-name: %s' % - net['net-name']) - args.append({'net-id': by_name['id']}) - elif net.get('port-id'): - args.append(net) - elif net.get('port-name'): - by_name = cloud.get_port(net['port-name']) - if not by_name: - module.fail_json( - msg='Could not find port by port-name: %s' % - net['port-name']) - args.append({'port-id': by_name['id']}) + + for net in _parse_nics(nics): + if net.get('net-id'): + args.append(net) + elif net.get('net-name'): + by_name = cloud.get_network(net['net-name']) + if not by_name: + module.fail_json( + msg='Could not find network by net-name: %s' % + net['net-name']) + args.append({'net-id': by_name['id']}) + elif net.get('port-id'): + args.append(net) + elif net.get('port-name'): + by_name = cloud.get_port(net['port-name']) + if not by_name: + module.fail_json( + msg='Could not find port by port-name: %s' % + net['port-name']) + args.append({'port-id': by_name['id']}) return args @@ -335,13 +354,20 @@ def _create_server(module, cloud): nics = _network_args(module, cloud) + if type(module.params['meta']) is str: + metas = {} + for kv_str in module.params['meta'].split(","): + k, v = kv_str.split("=") + metas[k] = v + module.params['meta'] = metas + bootkwargs = dict( name=module.params['name'], image=image_id, flavor=flavor_dict['id'], nics=nics, meta=module.params['meta'], - security_groups=module.params['security_groups'].split(','), + security_groups=module.params['security_groups'], userdata=module.params['userdata'], config_drive=module.params['config_drive'], ) @@ -352,7 +378,7 @@ def _create_server(module, cloud): server = cloud.create_server( ip_pool=module.params['floating_ip_pools'], ips=module.params['floating_ips'], - auto_ip=module.params['auto_floating_ip'], + auto_ip=module.params['auto_ip'], root_volume=module.params['root_volume'], terminate_volume=module.params['terminate_volume'], wait=module.params['wait'], timeout=module.params['timeout'], @@ -371,18 +397,18 @@ def _delete_floating_ip_list(cloud, server, extra_ips): def _check_floating_ips(module, cloud, server): changed = False - auto_floating_ip = module.params['auto_floating_ip'] + auto_ip = module.params['auto_ip'] floating_ips = module.params['floating_ips'] floating_ip_pools = module.params['floating_ip_pools'] - if floating_ip_pools or floating_ips or auto_floating_ip: + if floating_ip_pools or floating_ips or auto_ip: ips = openstack_find_nova_addresses(server.addresses, 'floating') if not ips: # If we're configured to have a floating but we don't have one, # let's add one server = cloud.add_ips_to_server( server, - auto_ip=auto_floating_ip, + auto_ip=auto_ip, ips=floating_ips, ip_pool=floating_ip_pools, ) @@ -434,12 +460,12 @@ def main(): flavor_ram = dict(default=None, type='int'), flavor_include = dict(default=None), key_name = dict(default=None), - security_groups = dict(default='default'), + security_groups = dict(default=['default'], type='list'), nics = dict(default=[], type='list'), meta = dict(default=None), userdata = dict(default=None), config_drive = dict(default=False, type='bool'), - auto_floating_ip = dict(default=True, type='bool'), + auto_ip = dict(default=True, type='bool', aliases=['auto_floating_ip', 'public_ip']), floating_ips = dict(default=None), floating_ip_pools = dict(default=None), root_volume = dict(default=None), @@ -448,8 +474,8 @@ def main(): ) module_kwargs = openstack_module_kwargs( mutually_exclusive=[ - ['auto_floating_ip', 'floating_ips'], - ['auto_floating_ip', 'floating_ip_pools'], + ['auto_ip', 'floating_ips'], + ['auto_ip', 'floating_ip_pools'], ['floating_ips', 'floating_ip_pools'], ['flavor', 'flavor_ram'], ['image', 'root_volume'], diff --git a/cloud/openstack/os_subnet.py b/cloud/openstack/os_subnet.py index d54268f415a..1913d95ce7e 100644 --- a/cloud/openstack/os_subnet.py +++ b/cloud/openstack/os_subnet.py @@ -302,7 +302,9 @@ def main(): changed = True else: changed = False - module.exit_json(changed=changed) + module.exit_json(changed=changed, + subnet=subnet, + id=subnet['id']) elif state == 'absent': if not subnet: diff --git a/cloud/openstack/os_subnets_facts.py b/cloud/openstack/os_subnets_facts.py new file mode 100644 index 00000000000..0f11fc6bcdc --- /dev/null +++ b/cloud/openstack/os_subnets_facts.py @@ -0,0 +1,154 @@ +#!/usr/bin/python + +# Copyright (c) 2015 Hewlett-Packard Development Company, L.P. +# +# This module is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This software is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this software. If not, see . + +try: + import shade + HAS_SHADE = True +except ImportError: + HAS_SHADE = False + +DOCUMENTATION = ''' +--- +module: os_subnets_facts +short_description: Retrieve facts about one or more OpenStack subnets. +version_added: "2.0" +author: "Davide Agnello (@dagnello)" +description: + - Retrieve facts about one or more subnets from OpenStack. +requirements: + - "python >= 2.6" + - "shade" +options: + subnet: + description: + - Name or ID of the subnet + required: false + filters: + description: + - A dictionary of meta data to use for further filtering. Elements of + this dictionary may be additional dictionaries. + required: false +extends_documentation_fragment: openstack +''' + +EXAMPLES = ''' +# Gather facts about previously created subnets +- os_subnets_facts: + auth: + auth_url: https://your_api_url.com:9000/v2.0 + username: user + password: password + project_name: someproject +- debug: var=openstack_subnets + +# Gather facts about a previously created subnet by name +- os_subnets_facts: + auth: + auth_url: https://your_api_url.com:9000/v2.0 + username: user + password: password + project_name: someproject + name: subnet1 +- debug: var=openstack_subnets + +# Gather facts about a previously created subnet with filter (note: name and + filters parameters are Not mutually exclusive) +- os_subnets_facts: + auth: + auth_url: https://your_api_url.com:9000/v2.0 + username: user + password: password + project_name: someproject + filters: + tenant_id: 55e2ce24b2a245b09f181bf025724cbe +- debug: var=openstack_subnets +''' + +RETURN = ''' +openstack_subnets: + description: has all the openstack facts about the subnets + returned: always, but can be null + type: complex + contains: + id: + description: Unique UUID. + returned: success + type: string + name: + description: Name given to the subnet. + returned: success + type: string + network_id: + description: Network ID this subnet belongs in. + returned: success + type: string + cidr: + description: Subnet's CIDR. + returned: success + type: string + gateway_ip: + description: Subnet's gateway ip. + returned: success + type: string + enable_dhcp: + description: DHCP enable flag for this subnet. + returned: success + type: bool + ip_version: + description: IP version for this subnet. + returned: success + type: int + tenant_id: + description: Tenant id associated with this subnet. + returned: success + type: string + dns_nameservers: + description: DNS name servers for this subnet. + returned: success + type: list of strings + allocation_pools: + description: Allocation pools associated with this subnet. + returned: success + type: list of dicts +''' + +def main(): + + argument_spec = openstack_full_argument_spec( + name=dict(required=False, default=None), + filters=dict(required=False, default=None) + ) + module = AnsibleModule(argument_spec) + + if not HAS_SHADE: + module.fail_json(msg='shade is required for this module') + + try: + cloud = shade.openstack_cloud(**module.params) + subnets = cloud.search_subnets(module.params['name'], + module.params['filters']) + module.exit_json(changed=False, ansible_facts=dict( + openstack_subnets=subnets)) + + except shade.OpenStackCloudException as e: + module.fail_json(msg=e.message) + +# this is magic, see lib/ansible/module_common.py +from ansible.module_utils.basic import * +from ansible.module_utils.openstack import * +if __name__ == '__main__': + main() diff --git a/cloud/openstack/os_user.py b/cloud/openstack/os_user.py new file mode 100644 index 00000000000..f5baa6fc75a --- /dev/null +++ b/cloud/openstack/os_user.py @@ -0,0 +1,212 @@ +#!/usr/bin/python +# Copyright (c) 2015 Hewlett-Packard Development Company, L.P. +# +# This module is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This software is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this software. If not, see . + + +try: + import shade + HAS_SHADE = True +except ImportError: + HAS_SHADE = False + +DOCUMENTATION = ''' +--- +module: os_user +short_description: Manage OpenStack Identity Users +extends_documentation_fragment: openstack +version_added: "2.0" +description: + - Manage OpenStack Identity users. Users can be created, + updated or deleted using this module. A user will be updated + if I(name) matches an existing user and I(state) is present. + The value for I(name) cannot be updated without deleting and + re-creating the user. +options: + name: + description: + - Username for the user + required: true + password: + description: + - Password for the user + required: true when I(state) is present + default: None + email: + description: + - Email address for the user + required: false + default: None + default_project: + description: + - Project name or ID that the user should be associated with by default + required: false + default: None + domain: + description: + - Domain to create the user in if the cloud supports domains + required: false + default: None + enabled: + description: + - Is the user enabled + required: false + default: True + state: + description: + - Should the resource be present or absent. + choices: [present, absent] + default: present +requirements: + - "python >= 2.6" + - "shade" +''' + +EXAMPLES = ''' +# Create a user +- os_user: + cloud: mycloud + state: present + name: demouser + password: secret + email: demo@example.com + domain: default + default_project: demo + +# Delete a user +- os_user: + cloud: mycloud + state: absent + name: demouser +''' + + +RETURN = ''' +user: + description: Dictionary describing the user. + returned: On success when I(state) is 'present' + type: dictionary + contains: + default_project_id: + description: User default project ID. Only present with Keystone >= v3. + type: string + sample: "4427115787be45f08f0ec22a03bfc735" + domain_id: + description: User domain ID. Only present with Keystone >= v3. + type: string + sample: "default" + email: + description: User email address + type: string + sample: "demo@example.com" + id: + description: User ID + type: string + sample: "f59382db809c43139982ca4189404650" + name: + description: User name + type: string + sample: "demouser" +''' + +def _needs_update(module, user): + keys = ('email', 'default_project', 'domain', 'enabled') + for key in keys: + if module.params[key] is not None and module.params[key] != user.get(key): + return True + + # We don't get password back in the user object, so assume any supplied + # password is a change. + if module.params['password'] is not None: + return True + + return False + +def main(): + + argument_spec = openstack_full_argument_spec( + name=dict(required=True), + password=dict(required=False, default=None), + email=dict(required=False, default=None), + default_project=dict(required=False, default=None), + domain=dict(required=False, default=None), + enabled=dict(default=True, type='bool'), + state=dict(default='present', choices=['absent', 'present']), + ) + + module_kwargs = openstack_module_kwargs() + module = AnsibleModule( + argument_spec, + required_if=[ + ('state', 'present', ['password']) + ], + **module_kwargs) + + if not HAS_SHADE: + module.fail_json(msg='shade is required for this module') + + name = module.params['name'] + password = module.params['password'] + email = module.params['email'] + default_project = module.params['default_project'] + domain = module.params['domain'] + enabled = module.params['enabled'] + state = module.params['state'] + + try: + cloud = shade.openstack_cloud(**module.params) + user = cloud.get_user(name) + + project_id = None + if default_project: + project = cloud.get_project(default_project) + if not project: + module.fail_json(msg='Default project %s is not valid' % default_project) + project_id = project['id'] + + if state == 'present': + if user is None: + user = cloud.create_user( + name=name, password=password, email=email, + default_project=default_project, domain_id=domain, + enabled=enabled) + changed = True + else: + if _needs_update(module, user): + user = cloud.update_user( + user['id'], password=password, email=email, + default_project=project_id, domain_id=domain, + enabled=enabled) + changed = True + else: + changed = False + module.exit_json(changed=changed, user=user) + + elif state == 'absent': + if user is None: + changed=False + else: + cloud.delete_user(user['id']) + changed=True + module.exit_json(changed=changed) + + except shade.OpenStackCloudException as e: + module.fail_json(msg=e.message, extra_data=e.extra_data) + +from ansible.module_utils.basic import * +from ansible.module_utils.openstack import * + + +if __name__ == '__main__': + main() diff --git a/database/mysql/mysql_user.py b/database/mysql/mysql_user.py index 1ea54b41b3a..3ac7c0890cd 100644 --- a/database/mysql/mysql_user.py +++ b/database/mysql/mysql_user.py @@ -32,7 +32,7 @@ options: required: true password: description: - - set the user's password + - set the user's password. (Required when adding a user) required: false default: null host: diff --git a/database/mysql/mysql_variables.py b/database/mysql/mysql_variables.py index d7187e85733..ab4848d6938 100644 --- a/database/mysql/mysql_variables.py +++ b/database/mysql/mysql_variables.py @@ -244,7 +244,8 @@ def main(): db_connection = MySQLdb.connect(host=module.params["login_host"], port=module.params["login_port"], user=login_user, passwd=login_password, db="mysql") cursor = db_connection.cursor() except Exception, e: - module.fail_json(msg="unable to connect to database, check login_user and login_password are correct or ~/.my.cnf has the credentials") + errno, errstr = e.args + module.fail_json(msg="unable to connect to database, check login_user and login_password are correct or ~/.my.cnf has the credentials (ERROR: %s %s)" % (errno, errstr)) mysqlvar_val = getvariable(cursor, mysqlvar) if mysqlvar_val is None: module.fail_json(msg="Variable not available \"%s\"" % mysqlvar, changed=False) diff --git a/files/copy.py b/files/copy.py index 8f6d3d32f28..da976f9a692 100644 --- a/files/copy.py +++ b/files/copy.py @@ -77,6 +77,13 @@ options: already existed. required: false version_added: "1.5" + remote_src: + description: + - If False, it will search for src at originating/master machine, if True it will go to the remote/target machine for the src. Default is False. + choices: [ "True", "False" ] + required: false + default: "False" + version_added: "2.0" extends_documentation_fragment: - files - validate diff --git a/files/find.py b/files/find.py index 659ec16026e..d6d1d42c4fd 100644 --- a/files/find.py +++ b/files/find.py @@ -25,8 +25,6 @@ import stat import fnmatch import time import re -import shutil - DOCUMENTATION = ''' --- @@ -50,17 +48,18 @@ options: required: false default: '*' description: - - One or more (shell type) file glob patterns, which restrict the list of files to be returned to - those whose basenames match at least one of the patterns specified. Multiple patterns can be - specified using a list. + - One or more (shell or regex) patterns, which type is controled by C(use_regex) option. + - The patterns restrict the list of files to be returned to those whose basenames match at + least one of the patterns specified. Multiple patterns can be specified using a list. + aliases: ['pattern'] contains: required: false default: null description: - - One or more re patterns which should be matched against the file content + - One or more re patterns which should be matched against the file content paths: required: true - aliases: [ "name" ] + aliases: [ "name", "path" ] description: - List of paths to the file or directory to search. All paths must be fully qualified. file_type: @@ -108,6 +107,12 @@ options: choices: [ True, False ] description: - Set this to true to retrieve a file's sha1 checksum + use_regex: + required: false + default: "False" + choices: [ True, False ] + description: + - If false the patterns are file globs (shell) if true they are python regexes ''' @@ -121,8 +126,11 @@ EXAMPLES = ''' # Recursively find /var/tmp files with last access time greater than 3600 seconds - find: paths="/var/tmp" age="3600" age_stamp=atime recurse=yes -# find /var/log files equal or greater than 10 megabytes ending with .log or .log.gz -- find: paths="/var/tmp" patterns="*.log","*.log.gz" size="10m" +# find /var/log files equal or greater than 10 megabytes ending with .old or .log.gz +- find: paths="/var/tmp" patterns="'*.old','*.log.gz'" size="10m" + +# find /var/log files equal or greater than 10 megabytes ending with .old or .log.gz via regex +- find: paths="/var/tmp" patterns="^.*?\.(?:old|log\.gz)$" size="10m" use_regex=True ''' RETURN = ''' @@ -152,13 +160,23 @@ examined: sample: 34 ''' -def pfilter(f, patterns=None): +def pfilter(f, patterns=None, use_regex=False): '''filter using glob patterns''' + if patterns is None: return True - for p in patterns: - if fnmatch.fnmatch(f, p): - return True + + if use_regex: + for p in patterns: + r = re.compile(p) + if r.match(f): + return True + else: + + for p in patterns: + if fnmatch.fnmatch(f, p): + return True + return False @@ -236,8 +254,8 @@ def statinfo(st): def main(): module = AnsibleModule( argument_spec = dict( - paths = dict(required=True, aliases=['name'], type='list'), - patterns = dict(default=['*'], type='list'), + paths = dict(required=True, aliases=['name','path'], type='list'), + patterns = dict(default=['*'], type='list', aliases=['pattern']), contains = dict(default=None, type='str'), file_type = dict(default="file", choices=['file', 'directory'], type='str'), age = dict(default=None, type='str'), @@ -247,7 +265,9 @@ def main(): hidden = dict(default="False", type='bool'), follow = dict(default="False", type='bool'), get_checksum = dict(default="False", type='bool'), + use_regex = dict(default="False", type='bool'), ), + supports_check_mode=True, ) params = module.params @@ -292,16 +312,21 @@ def main(): if os.path.basename(fsname).startswith('.') and not params['hidden']: continue - st = os.stat(fsname) + try: + st = os.stat(fsname) + except: + msg+="%s was skipped as it does not seem to be a valid file or it cannot be accessed\n" % fsname + continue + r = {'path': fsname} if stat.S_ISDIR(st.st_mode) and params['file_type'] == 'directory': - if pfilter(fsobj, params['patterns']) and agefilter(st, now, age, params['age_stamp']): + if pfilter(fsobj, params['patterns'], params['use_regex']) and agefilter(st, now, age, params['age_stamp']): r.update(statinfo(st)) filelist.append(r) elif stat.S_ISREG(st.st_mode) and params['file_type'] == 'file': - if pfilter(fsobj, params['patterns']) and \ + if pfilter(fsobj, params['patterns'], params['use_regex']) and \ agefilter(st, now, age, params['age_stamp']) and \ sizefilter(st, size) and \ contentfilter(fsname, params['contains']): @@ -314,7 +339,7 @@ def main(): if not params['recurse']: break else: - msg+="%s was skipped as it does not seem to be a valid directory or it cannot be accessed\n" + msg+="%s was skipped as it does not seem to be a valid directory or it cannot be accessed\n" % npath matched = len(filelist) module.exit_json(files=filelist, changed=False, msg=msg, matched=matched, examined=looked) diff --git a/files/ini_file.py b/files/ini_file.py index fff153af6ad..cb3edb2cff2 100644 --- a/files/ini_file.py +++ b/files/ini_file.py @@ -65,6 +65,12 @@ options: description: - all arguments accepted by the M(file) module also work here required: false + state: + description: + - If set to C(absent) the option or section will be removed if present instead of created. + required: false + default: "present" + choices: [ "present", "absent" ] notes: - While it is possible to add an I(option) without specifying a I(value), this makes no sense. @@ -110,21 +116,14 @@ def do_ini(module, filename, section=None, option=None, value=None, state='prese if state == 'absent': - if option is None and value is None: - if cp.has_section(section): - cp.remove_section(section) - changed = True + if option is None: + changed = cp.remove_section(section) else: - if option is not None: - try: - if cp.get(section, option): - cp.remove_option(section, option) - changed = True - except ConfigParser.InterpolationError: - cp.remove_option(section, option) - changed = True - except: - pass + try: + changed = cp.remove_option(section, option) + except ConfigParser.NoSectionError: + # Option isn't present if the section isn't either + pass if state == 'present': @@ -212,4 +211,5 @@ def main(): # import module snippets from ansible.module_utils.basic import * -main() +if __name__ == '__main__': + main() diff --git a/files/stat.py b/files/stat.py index 2e088fc8dbd..61c77a2ef31 100644 --- a/files/stat.py +++ b/files/stat.py @@ -42,11 +42,18 @@ options: aliases: [] get_checksum: description: - - Whether to return a checksum of the file (currently sha1) + - Whether to return a checksum of the file (default sha1) required: false default: yes aliases: [] version_added: "1.8" + checksum_algorithm: + description: + - Algorithm to determine checksum of file. Will throw an error if the host is unable to use specified algorithm. + required: false + choices: [ 'sha1', 'sha224', 'sha256', 'sha384', 'sha512' ] + default: sha1 + version_added: "2.0" author: "Bruce Pennypacker (@bpennypacker)" ''' @@ -84,6 +91,9 @@ EXAMPLES = ''' # Don't do md5 checksum - stat: path=/path/to/myhugefile get_md5=no + +# Use sha256 to calculate checksum +- stat: path=/path/to/something checksum_algorithm=sha256 ''' RETURN = ''' @@ -245,8 +255,8 @@ stat: lnk_source: description: Original path returned: success, path exists and user can read stats and the path is a symbolic link - type: boolean - sample: True + type: string + sample: /home/foobar/21102015-1445431274-908472971 md5: description: md5 hash of the path returned: success, path exists and user can read stats and path supports hashing and md5 is supported @@ -254,7 +264,7 @@ stat: sample: f88fa92d8cf2eeecf4c0a50ccc96d0c0 checksum: description: hash of the path - returned: success, path exists and user can read stats and path supports hashing + returned: success, path exists, user can read stats, path supports hashing and supplied checksum algorithm is available type: string sample: 50ba294cdf28c0d5bcde25708df53346825a429f pw_name: @@ -281,7 +291,8 @@ def main(): path = dict(required=True), follow = dict(default='no', type='bool'), get_md5 = dict(default='yes', type='bool'), - get_checksum = dict(default='yes', type='bool') + get_checksum = dict(default='yes', type='bool'), + checksum_algorithm = dict(default='sha1', type='str', choices=['sha1', 'sha224', 'sha256', 'sha384', 'sha512']) ), supports_check_mode = True ) @@ -291,6 +302,7 @@ def main(): follow = module.params.get('follow') get_md5 = module.params.get('get_md5') get_checksum = module.params.get('get_checksum') + checksum_algorithm = module.params.get('checksum_algorithm') try: if follow: @@ -351,8 +363,7 @@ def main(): d['md5'] = None if S_ISREG(mode) and get_checksum and os.access(path,os.R_OK): - d['checksum'] = module.sha1(path) - + d['checksum'] = module.digest_from_file(path, checksum_algorithm) try: pw = pwd.getpwuid(st.st_uid) @@ -370,4 +381,4 @@ def main(): # import module snippets from ansible.module_utils.basic import * -main() +main() \ No newline at end of file diff --git a/network/basics/uri.py b/network/basics/uri.py index 3babba6d609..5c0907523b8 100644 --- a/network/basics/uri.py +++ b/network/basics/uri.py @@ -25,6 +25,8 @@ import shutil import tempfile import base64 import datetime +from distutils.version import LooseVersion + try: import json except ImportError: @@ -44,7 +46,6 @@ options: - HTTP or HTTPS URL in the form (http|https)://host.domain[:port]/path required: true default: null - aliases: [] dest: description: - path of where to download the file to (if desired). If I(dest) is a directory, the basename of the file on the remote server will be used. @@ -74,7 +75,7 @@ options: version_added: "2.0" method: description: - - The HTTP method of the request or response. + - The HTTP method of the request or response. It MUST be uppercase. required: false choices: [ "GET", "POST", "PUT", "HEAD", "DELETE", "OPTIONS", "PATCH", "TRACE", "CONNECT", "REFRESH" ] default: "GET" @@ -144,7 +145,8 @@ options: version_added: '1.9.2' # informational: requirements for nodes -requirements: [ urlparse, httplib2 ] +requirements: + - httplib2 >= 0.7.0 author: "Romeo Theriault (@romeotheriault)" ''' @@ -199,11 +201,15 @@ EXAMPLES = ''' ''' -HAS_HTTPLIB2 = True +HAS_HTTPLIB2 = False + try: import httplib2 -except ImportError: - HAS_HTTPLIB2 = False + if LooseVersion(httplib2.__version__) >= LooseVersion('0.7'): + HAS_HTTPLIB2 = True +except ImportError, AttributeError: + # AttributeError if __version__ is not present + pass HAS_URLPARSE = True @@ -383,7 +389,7 @@ def main(): ) if not HAS_HTTPLIB2: - module.fail_json(msg="httplib2 is not installed") + module.fail_json(msg="httplib2 >= 0.7 is not installed") if not HAS_URLPARSE: module.fail_json(msg="urlparse is not installed") @@ -474,7 +480,7 @@ def main(): content_type, params = cgi.parse_header(uresp['content_type']) if 'charset' in params: content_encoding = params['charset'] - u_content = unicode(content, content_encoding, errors='xmlcharrefreplace') + u_content = unicode(content, content_encoding, errors='replace') if content_type.startswith('application/json') or \ content_type.startswith('text/json'): try: @@ -483,7 +489,7 @@ def main(): except: pass else: - u_content = unicode(content, content_encoding, errors='xmlcharrefreplace') + u_content = unicode(content, content_encoding, errors='replace') if resp['status'] not in status_code: module.fail_json(msg="Status code was not " + str(status_code), content=u_content, **uresp) diff --git a/packaging/os/apt.py b/packaging/os/apt.py index 1fd770f710e..d99eb85ff7e 100755 --- a/packaging/os/apt.py +++ b/packaging/os/apt.py @@ -559,7 +559,7 @@ def main(): if not HAS_PYTHON_APT: try: - module.run_command('apt-get update && apt-get install python-apt -y -q', use_unsafe_shell=True, check_rc=True) + module.run_command('apt-get update && apt-get install python-apt -y -q --force-yes', use_unsafe_shell=True, check_rc=True) global apt, apt_pkg import apt import apt.debfile diff --git a/packaging/os/yum.py b/packaging/os/yum.py index b54df227973..e1e3341a075 100644 --- a/packaging/os/yum.py +++ b/packaging/os/yum.py @@ -130,6 +130,15 @@ notes: that the other packages come from (such as epel-release) then that package needs to be installed in a separate task. This mimics yum's command line behaviour. + - 'Yum itself has two types of groups. "Package groups" are specified in the + rpm itself while "environment groups" are specified in a separate file + (usually by the distribution). Unfortunately, this division becomes + apparent to ansible users because ansible needs to operate on the group + of packages in a single transaction and yum requires groups to be specified + in different ways when used in that way. Package groups are specified as + "@development-tools" and environment groups are "@^gnome-desktop-environment". + Use the "yum group list" command to see which category of group the group + you want to install falls into.' # informational: requirements for nodes requirements: [ yum ] author: @@ -161,6 +170,9 @@ EXAMPLES = ''' - name: install the 'Development tools' package group yum: name="@Development tools" state=present + +- name: install the 'Gnome desktop' environment group + yum: name="@^gnome-desktop-environment" state=present ''' # 64k. Number of bytes to read at a time when manually downloading pkgs via a url @@ -755,7 +767,11 @@ def latest(module, items, repoq, yum_basecmd, conf_file, en_repos, dis_repos): if update_all: cmd = yum_basecmd + ['update'] + will_update = set(updates.keys()) + will_update_from_other_package = dict() else: + will_update = set() + will_update_from_other_package = dict() for spec in items: # some guess work involved with groups. update @ will install the group if missing if spec.startswith('@'): @@ -779,8 +795,19 @@ def latest(module, items, repoq, yum_basecmd, conf_file, en_repos, dis_repos): nothing_to_do = False break - if spec in pkgs['update'] and spec in updates.keys(): - nothing_to_do = False + # this contains the full NVR and spec could contain wildcards + # or virtual provides (like "python-*" or "smtp-daemon") while + # updates contains name only. + this_name_only = '-'.join(this.split('-')[:-2]) + if spec in pkgs['update'] and this_name_only in updates.keys(): + nothing_to_do = False + will_update.add(spec) + # Massage the updates list + if spec != this_name_only: + # For reporting what packages would be updated more + # succinctly + will_update_from_other_package[spec] = this_name_only + break if nothing_to_do: res['results'].append("All packages providing %s are up to date" % spec) @@ -793,12 +820,6 @@ def latest(module, items, repoq, yum_basecmd, conf_file, en_repos, dis_repos): res['msg'] += "The following packages have pending transactions: %s" % ", ".join(conflicts) module.fail_json(**res) - # list of package updates - if update_all: - will_update = updates.keys() - else: - will_update = [u for u in pkgs['update'] if u in updates.keys() or u.startswith('@')] - # check_mode output if module.check_mode: to_update = [] @@ -806,6 +827,9 @@ def latest(module, items, repoq, yum_basecmd, conf_file, en_repos, dis_repos): if w.startswith('@'): to_update.append((w, None)) msg = '%s will be updated' % w + elif w not in updates: + other_pkg = will_update_from_other_package[w] + to_update.append((w, 'because of (at least) %s-%s.%s from %s' % (other_pkg, updates[other_pkg]['version'], updates[other_pkg]['dist'], updates[other_pkg]['repo']))) else: to_update.append((w, '%s.%s from %s' % (updates[w]['version'], updates[w]['dist'], updates[w]['repo']))) diff --git a/source_control/git.py b/source_control/git.py index 4b1620392a0..d42b284abc5 100644 --- a/source_control/git.py +++ b/source_control/git.py @@ -453,7 +453,7 @@ def is_local_branch(git_path, module, dest, branch): def is_not_a_branch(git_path, module, dest): branches = get_branches(git_path, module, dest) for b in branches: - if b.startswith('* ') and 'no branch' in b: + if b.startswith('* ') and ('no branch' in b or 'detached from' in b): return True return False diff --git a/source_control/subversion.py b/source_control/subversion.py index 24cc065c5a4..64a1b38b044 100644 --- a/source_control/subversion.py +++ b/source_control/subversion.py @@ -171,9 +171,10 @@ class Subversion(object): '''True if revisioned files have been added or modified. Unrevisioned files are ignored.''' lines = self._exec(["status", "--quiet", "--ignore-externals", self.dest]) # The --quiet option will return only modified files. - + # Match only revisioned files, i.e. ignore status '?'. + regex = re.compile(r'^[^?X]') # Has local mods if more than 0 modifed revisioned files. - return len(filter(len, lines)) > 0 + return len(filter(regex.match, lines)) > 0 def needs_update(self): curr, url = self.get_revision() diff --git a/system/cron.py b/system/cron.py index 57c7fd40836..63319096c42 100644 --- a/system/cron.py +++ b/system/cron.py @@ -67,6 +67,7 @@ options: cron_file: description: - If specified, uses this file in cron.d instead of an individual user's crontab. + To use the C(cron_file) parameter you must specify the C(user) as well. required: false default: null backup: diff --git a/system/hostname.py b/system/hostname.py index 9e7f6a4ef70..2914088691a 100644 --- a/system/hostname.py +++ b/system/hostname.py @@ -481,6 +481,15 @@ class ScientificLinuxHostname(Hostname): else: strategy_class = RedHatStrategy +class OracleLinuxHostname(Hostname): + platform = 'Linux' + distribution = 'Oracle linux server' + distribution_version = get_distribution_version() + if distribution_version and LooseVersion(distribution_version) >= LooseVersion("7"): + strategy_class = SystemdStrategy + else: + strategy_class = RedHatStrategy + class AmazonLinuxHostname(Hostname): platform = 'Linux' distribution = 'Amazon' diff --git a/system/ping.py b/system/ping.py index bea7fb22f1d..1449cf5dca9 100644 --- a/system/ping.py +++ b/system/ping.py @@ -23,19 +23,20 @@ DOCUMENTATION = ''' --- module: ping version_added: historical -short_description: Try to connect to host and return C(pong) on success. +short_description: Try to connect to host, veryify a usable python and return C(pong) on success. description: - A trivial test module, this module always returns C(pong) on successful contact. It does not make sense in playbooks, but it is useful from - C(/usr/bin/ansible) + C(/usr/bin/ansible) to verify the ability to login and that a usable python is configured. + - This is NOT ICMP ping, this is just a trivial test module. options: {} -author: +author: - "Ansible Core Team" - "Michael DeHaan" ''' EXAMPLES = ''' -# Test 'webservers' status +# Test we can logon to 'webservers' and execute python with json lib. ansible webservers -m ping ''' diff --git a/system/service.py b/system/service.py index f9a8b1e24c1..2b8dbb8696c 100644 --- a/system/service.py +++ b/system/service.py @@ -395,7 +395,7 @@ class LinuxService(Service): location = dict() for binary in binaries: - location[binary] = self.module.get_bin_path(binary) + location[binary] = self.module.get_bin_path(binary, opt_dirs=paths) for initdir in initpaths: initscript = "%s/%s" % (initdir,self.name) @@ -403,25 +403,31 @@ class LinuxService(Service): self.svc_initscript = initscript def check_systemd(): - # verify systemd is installed (by finding systemctl) - if not location.get('systemctl', False): - return False - # Check if init is the systemd command, using comm as cmdline could be symlink - try: - f = open('/proc/1/comm', 'r') - except IOError, err: - # If comm doesn't exist, old kernel, no systemd - return False + # tools must be installed + if location.get('systemctl',False): - for line in f: - if 'systemd' in line: - return True + # this should show if systemd is the boot init system + # these mirror systemd's own sd_boot test http://www.freedesktop.org/software/systemd/man/sd_booted.html + for canary in ["/run/systemd/system/", "/dev/.run/systemd/", "/dev/.systemd/"]: + if os.path.exists(canary): + return True + + # If all else fails, check if init is the systemd command, using comm as cmdline could be symlink + try: + f = open('/proc/1/comm', 'r') + except IOError: + # If comm doesn't exist, old kernel, no systemd + return False + + for line in f: + if 'systemd' in line: + return True return False # Locate a tool to enable/disable a service - if location.get('systemctl',False) and check_systemd(): + if check_systemd(): # service is managed by systemd self.__systemd_unit = self.name self.svc_cmd = location['systemctl'] @@ -699,7 +705,8 @@ class LinuxService(Service): (rc, out, err) = self.execute_command("%s --list %s" % (self.enable_cmd, self.name)) if not self.name in out: self.module.fail_json(msg="service %s does not support chkconfig" % self.name) - state = out.split()[-1] + #TODO: look back on why this is here + #state = out.split()[-1] # Check if we're already in the correct state if "3:%s" % action in out and "5:%s" % action in out: @@ -961,7 +968,6 @@ class FreeBsdService(Service): self.rcconf_file = rcfile rc, stdout, stderr = self.execute_command("%s %s %s %s" % (self.svc_cmd, self.name, 'rcvar', self.arguments)) - cmd = "%s %s %s %s" % (self.svc_cmd, self.name, 'rcvar', self.arguments) try: rcvars = shlex.split(stdout, comments=True) except: diff --git a/system/sysctl.py b/system/sysctl.py index e48d5df74c5..db1652955fc 100644 --- a/system/sysctl.py +++ b/system/sysctl.py @@ -123,6 +123,8 @@ class SysctlModule(object): def process(self): + self.platform = get_platform().lower() + # Whitespace is bad self.args['name'] = self.args['name'].strip() self.args['value'] = self._parse_value(self.args['value']) @@ -206,7 +208,11 @@ class SysctlModule(object): # Use the sysctl command to find the current value def get_token_curr_value(self, token): - thiscmd = "%s -e -n %s" % (self.sysctl_cmd, token) + if self.platform == 'openbsd': + # openbsd doesn't support -e, just drop it + thiscmd = "%s -n %s" % (self.sysctl_cmd, token) + else: + thiscmd = "%s -e -n %s" % (self.sysctl_cmd, token) rc,out,err = self.module.run_command(thiscmd) if rc != 0: return None @@ -217,7 +223,11 @@ class SysctlModule(object): def set_token_value(self, token, value): if len(value.split()) > 0: value = '"' + value + '"' - thiscmd = "%s -w %s=%s" % (self.sysctl_cmd, token, value) + if self.platform == 'openbsd': + # openbsd doesn't accept -w, but since it's not needed, just drop it + thiscmd = "%s %s=%s" % (self.sysctl_cmd, token, value) + else: + thiscmd = "%s -w %s=%s" % (self.sysctl_cmd, token, value) rc,out,err = self.module.run_command(thiscmd) if rc != 0: self.module.fail_json(msg='setting %s failed: %s' % (token, out + err)) @@ -227,9 +237,20 @@ class SysctlModule(object): # Run sysctl -p def reload_sysctl(self): # do it - if get_platform().lower() == 'freebsd': + if self.platform == 'freebsd': # freebsd doesn't support -p, so reload the sysctl service rc,out,err = self.module.run_command('/etc/rc.d/sysctl reload') + elif self.platform == 'openbsd': + # openbsd doesn't support -p and doesn't have a sysctl service, + # so we have to set every value with its own sysctl call + for k, v in self.file_values.items(): + rc = 0 + if k != self.args['name']: + rc = self.set_token_value(k, v) + if rc != 0: + break + if rc == 0 and self.args['state'] == "present": + rc = self.set_token_value(self.args['name'], self.args['value']) else: # system supports reloading via the -p flag to sysctl, so we'll use that sysctl_args = [self.sysctl_cmd, '-p', self.sysctl_file] diff --git a/system/user.py b/system/user.py index 499228953b2..c04b748f068 100755 --- a/system/user.py +++ b/system/user.py @@ -1352,20 +1352,21 @@ class SunOS(User): cmd.append('-s') cmd.append(self.shell) - if self.module.check_mode: - return (0, '', '') - else: - # modify the user if cmd will do anything - if cmd_len != len(cmd): + # modify the user if cmd will do anything + if cmd_len != len(cmd): + (rc, out, err) = (0, '', '') + if not self.module.check_mode: cmd.append(self.name) (rc, out, err) = self.execute_command(cmd) if rc is not None and rc != 0: self.module.fail_json(name=self.name, msg=err, rc=rc) - else: - (rc, out, err) = (None, '', '') + else: + (rc, out, err) = (None, '', '') - # we have to set the password by editing the /etc/shadow file - if self.update_password == 'always' and self.password is not None and info[1] != self.password: + # we have to set the password by editing the /etc/shadow file + if self.update_password == 'always' and self.password is not None and info[1] != self.password: + (rc, out, err) = (0, '', '') + if not self.module.check_mode: try: lines = [] for line in open(self.SHADOWFILE, 'rb').readlines(): @@ -1382,7 +1383,7 @@ class SunOS(User): except Exception, err: self.module.fail_json(msg="failed to update users password: %s" % str(err)) - return (rc, out, err) + return (rc, out, err) # =========================================== class DarwinUser(User): @@ -2044,7 +2045,7 @@ def main(): comment=dict(default=None, type='str'), home=dict(default=None, type='str'), shell=dict(default=None, type='str'), - password=dict(default=None, type='str'), + password=dict(default=None, type='str', no_log=True), login_class=dict(default=None, type='str'), # following options are specific to userdel force=dict(default='no', type='bool'), @@ -2062,7 +2063,7 @@ def main(): ssh_key_type=dict(default=ssh_defaults['type'], type='str'), ssh_key_file=dict(default=None, type='str'), ssh_key_comment=dict(default=ssh_defaults['comment'], type='str'), - ssh_key_passphrase=dict(default=None, type='str'), + ssh_key_passphrase=dict(default=None, type='str', no_log=True), update_password=dict(default='always',choices=['always','on_create'],type='str'), expires=dict(default=None, type='float'), ), @@ -2160,4 +2161,5 @@ def main(): # import module snippets from ansible.module_utils.basic import * -main() +if __name__ == '__main__': + main() diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 00000000000..93253de97a3 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,2 @@ +mock +pytest diff --git a/test/unit/cloud/openstack/test_os_server.py b/test/unit/cloud/openstack/test_os_server.py new file mode 100644 index 00000000000..bb1f79ad2f9 --- /dev/null +++ b/test/unit/cloud/openstack/test_os_server.py @@ -0,0 +1,221 @@ +import mock +import pytest +import yaml +import inspect +import collections + +from cloud.openstack import os_server + + +class AnsibleFail(Exception): + pass + + +class AnsibleExit(Exception): + pass + + +def params_from_doc(func): + '''This function extracts the docstring from the specified function, + parses it as a YAML document, and returns parameters for the os_server + module.''' + + doc = inspect.getdoc(func) + cfg = yaml.load(doc) + + for task in cfg: + for module, params in task.items(): + for k, v in params.items(): + if k in ['nics'] and type(v) == str: + params[k] = [v] + task[module] = collections.defaultdict(str, + params) + + return cfg[0]['os_server'] + + +class FakeCloud (object): + ports = [ + {'name': 'port1', 'id': '1234'}, + {'name': 'port2', 'id': '4321'}, + ] + + networks = [ + {'name': 'network1', 'id': '5678'}, + {'name': 'network2', 'id': '8765'}, + ] + + images = [ + {'name': 'cirros', 'id': '1'}, + {'name': 'fedora', 'id': '2'}, + ] + + flavors = [ + {'name': 'm1.small', 'id': '1', 'flavor_ram': 1024}, + {'name': 'm1.tiny', 'id': '2', 'flavor_ram': 512}, + ] + + def _find(self, source, name): + for item in source: + if item['name'] == name or item['id'] == name: + return item + + def get_image_id(self, name, exclude=None): + image = self._find(self.images, name) + if image: + return image['id'] + + def get_flavor(self, name): + return self._find(self.flavors, name) + + def get_flavor_by_ram(self, ram, include=None): + for flavor in self.flavors: + if flavor['ram'] >= ram and (include is None or include in + flavor['name']): + return flavor + + def get_port(self, name): + return self._find(self.ports, name) + + def get_network(self, name): + return self._find(self.networks, name) + + create_server = mock.MagicMock() + + +class TestNetworkArgs(object): + '''This class exercises the _network_args function of the + os_server module. For each test, we parse the YAML document + contained in the docstring to retrieve the module parameters for the + test.''' + + def setup_method(self, method): + self.cloud = FakeCloud() + self.module = mock.MagicMock() + self.module.params = params_from_doc(method) + + def test_nics_string_net_id(self): + ''' + - os_server: + nics: net-id=1234 + ''' + args = os_server._network_args(self.module, self.cloud) + assert(args[0]['net-id'] == '1234') + + def test_nics_string_net_id_list(self): + ''' + - os_server: + nics: net-id=1234,net-id=4321 + ''' + args = os_server._network_args(self.module, self.cloud) + assert(args[0]['net-id'] == '1234') + assert(args[1]['net-id'] == '4321') + + def test_nics_string_port_id(self): + ''' + - os_server: + nics: port-id=1234 + ''' + args = os_server._network_args(self.module, self.cloud) + assert(args[0]['port-id'] == '1234') + + def test_nics_string_net_name(self): + ''' + - os_server: + nics: net-name=network1 + ''' + args = os_server._network_args(self.module, self.cloud) + assert(args[0]['net-id'] == '5678') + + def test_nics_string_port_name(self): + ''' + - os_server: + nics: port-name=port1 + ''' + args = os_server._network_args(self.module, self.cloud) + assert(args[0]['port-id'] == '1234') + + def test_nics_structured_net_id(self): + ''' + - os_server: + nics: + - net-id: '1234' + ''' + args = os_server._network_args(self.module, self.cloud) + assert(args[0]['net-id'] == '1234') + + def test_nics_structured_mixed(self): + ''' + - os_server: + nics: + - net-id: '1234' + - port-name: port1 + - 'net-name=network1,port-id=4321' + ''' + args = os_server._network_args(self.module, self.cloud) + assert(args[0]['net-id'] == '1234') + assert(args[1]['port-id'] == '1234') + assert(args[2]['net-id'] == '5678') + assert(args[3]['port-id'] == '4321') + + +class TestCreateServer(object): + def setup_method(self, method): + self.cloud = FakeCloud() + self.module = mock.MagicMock() + self.module.params = params_from_doc(method) + self.module.fail_json.side_effect = AnsibleFail() + self.module.exit_json.side_effect = AnsibleExit() + + self.meta = mock.MagicMock() + self.meta.gett_hostvars_from_server.return_value = { + 'id': '1234' + } + os_server.meta = self.meta + + def test_create_server(self): + ''' + - os_server: + image: cirros + flavor: m1.tiny + nics: + - net-name: network1 + ''' + with pytest.raises(AnsibleExit): + os_server._create_server(self.module, self.cloud) + + assert(self.cloud.create_server.call_count == 1) + assert(self.cloud.create_server.call_args[1]['image'] + == self.cloud.get_image_id('cirros')) + assert(self.cloud.create_server.call_args[1]['flavor'] + == self.cloud.get_flavor('m1.tiny')['id']) + assert(self.cloud.create_server.call_args[1]['nics'][0]['net-id'] + == self.cloud.get_network('network1')['id']) + + def test_create_server_bad_flavor(self): + ''' + - os_server: + image: cirros + flavor: missing_flavor + nics: + - net-name: network1 + ''' + with pytest.raises(AnsibleFail): + os_server._create_server(self.module, self.cloud) + + assert('missing_flavor' in + self.module.fail_json.call_args[1]['msg']) + + def test_create_server_bad_nic(self): + ''' + - os_server: + image: cirros + flavor: m1.tiny + nics: + - net-name: missing_network + ''' + with pytest.raises(AnsibleFail): + os_server._create_server(self.module, self.cloud) + + assert('missing_network' in + self.module.fail_json.call_args[1]['msg']) diff --git a/web_infrastructure/apache2_module.py b/web_infrastructure/apache2_module.py index cb43ba9b0eb..ecc176a20b3 100644 --- a/web_infrastructure/apache2_module.py +++ b/web_infrastructure/apache2_module.py @@ -56,7 +56,7 @@ def _disable_module(module): result, stdout, stderr = module.run_command("%s %s" % (a2dismod_binary, name)) - if re.match(r'.*\b' + name + r' already disabled', stdout, re.S): + if re.match(r'.*\b' + name + r' already disabled', stdout, re.S|re.M): module.exit_json(changed = False, result = "Success") elif result != 0: module.fail_json(msg="Failed to disable module %s: %s" % (name, stdout)) @@ -71,7 +71,7 @@ def _enable_module(module): result, stdout, stderr = module.run_command("%s %s" % (a2enmod_binary, name)) - if re.match(r'.*\b' + name + r' already enabled', stdout, re.S): + if re.match(r'.*\b' + name + r' already enabled', stdout, re.S|re.M): module.exit_json(changed = False, result = "Success") elif result != 0: module.fail_json(msg="Failed to enable module %s: %s" % (name, stdout)) diff --git a/windows/setup.ps1 b/windows/setup.ps1 index a6f439d7338..0b3e2c897e3 100644 --- a/windows/setup.ps1 +++ b/windows/setup.ps1 @@ -64,6 +64,15 @@ Set-Attr $result.ansible_facts "ansible_os_name" ($win32_os.Name.Split('|')[0]). Set-Attr $result.ansible_facts "ansible_distribution" $osversion.VersionString Set-Attr $result.ansible_facts "ansible_distribution_version" $osversion.Version.ToString() +$date = New-Object psobject +Set-Attr $date "date" (Get-Date -format d) +Set-Attr $date "year" (Get-Date -format yyyy) +Set-Attr $date "month" (Get-Date -format MM) +Set-Attr $date "day" (Get-Date -format dd) +Set-Attr $date "hour" (Get-Date -format HH) +Set-Attr $date "iso8601" (Get-Date -format s) +Set-Attr $result.ansible_facts "ansible_date_time" $date + Set-Attr $result.ansible_facts "ansible_totalmem" $capacity Set-Attr $result.ansible_facts "ansible_lastboot" $win32_os.lastbootuptime.ToString("u") diff --git a/windows/win_file.ps1 b/windows/win_file.ps1 index f8416120abf..f387780123c 100644 --- a/windows/win_file.ps1 +++ b/windows/win_file.ps1 @@ -71,18 +71,15 @@ If (Test-Path $path) } Else { - # Only files have the .Directory attribute. - If ( $state -eq "directory" -and $fileinfo.Directory ) + If ( $state -eq "directory" -and -not $fileinfo.PsIsContainer ) { Fail-Json (New-Object psobject) "path is not a directory" } - # Only files have the .Directory attribute. - If ( $state -eq "file" -and -not $fileinfo.Directory ) + If ( $state -eq "file" -and $fileinfo.PsIsContainer ) { Fail-Json (New-Object psobject) "path is not a file" } - } } Else diff --git a/windows/win_lineinfile.ps1 b/windows/win_lineinfile.ps1 index ddf1d4e3000..4ba9086a6e9 100644 --- a/windows/win_lineinfile.ps1 +++ b/windows/win_lineinfile.ps1 @@ -387,8 +387,11 @@ Elseif (Test-Path $dest) { $found = $FALSE; Foreach ($encoding in $sortedlist.GetValueList()) { $preamble = $encoding.GetPreamble(); - If ($preamble) { - Foreach ($i in 0..$preamble.Length) { + If ($preamble -and $bom) { + Foreach ($i in 0..($preamble.Length - 1)) { + If ($i -ge $bom.Length) { + break; + } If ($preamble[$i] -ne $bom[$i]) { break; } @@ -427,7 +430,7 @@ If ($state -eq "present") { } Else { - If ($regex -eq $FALSE -and $line -eq $FALSE) { + If ($regexp -eq $FALSE -and $line -eq $FALSE) { Fail-Json (New-Object psobject) "one of line= or regexp= is required with state=absent"; }