Repository: ansible-collections/amazon.aws

Issue:
Support For Additional Placement Options and LicenseSpecifications
### Summary

In order to launch mac instances using the `amazon.aws.ec2_instance` module, support for HostGroupResourceArn placement option and LicensConfigurations would be required as an alternative to only specifying tenancy as dedicated to offer more control on instance launch.

### Issue Type

Feature Idea

### Component Name

ec2_instance

### Additional Information

<!--- Paste example playbooks or commands between quotes below -->
```yaml
- name: start a mac instance with a host resource group and license configuration
  amazon.aws.ec2_instance:
    name: "public-compute-instance"
    key_name: "prod-ssh-key"
    vpc_subnet_id: subnet-5ca1ab1e
    instance_type: mac1.metal
    security_group: default
    placement:
      hostresourcegrouparn: arn:aws:resource-groups:us-east-1:123456789012:group/MyResourceGroup
    license_specifications:
      - license_configuration_arn: arn:aws:license-manager:us-east-1: 123456789012:license-configuration:lic-0123456789
    image_id: ami-123456
    tags:
      Environment: Testing
```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:

Resolving patch:
diff --git a/plugins/modules/ec2_instance.py b/plugins/modules/ec2_instance.py
--- a/plugins/modules/ec2_instance.py
+++ b/plugins/modules/ec2_instance.py
@@ -258,6 +258,7 @@
   tenancy:
     description:
       - What type of tenancy to allow an instance to use. Default is shared tenancy. Dedicated tenancy will incur additional charges.
+      - This field is deprecated and will be removed in a release after 2025-12-01, use I(placement) instead.
     choices: ['dedicated', 'default']
     type: str
   termination_protection:
@@ -325,7 +326,53 @@
   placement_group:
     description:
       - The placement group that needs to be assigned to the instance.
+      - This field is deprecated and will be removed in a release after 2025-12-01, use I(placement) instead.
     type: str
+  placement:
+    description:
+      - The location where the instance launched, if applicable.
+    type: dict
+    version_added: 7.0.0
+    suboptions:
+      affinity:
+        description: The affinity setting for the instance on the Dedicated Host.
+        type: str
+        required: false
+      availability_zone:
+        description: The Availability Zone of the instance.
+        type: str
+        required: false
+      group_name:
+        description: The name of the placement group the instance is in.
+        type: str
+        required: false
+      host_id:
+        description: The ID of the Dedicated Host on which the instance resides.
+        type: str
+        required: false
+      host_resource_group_arn:
+        description: The ARN of the host resource group in which to launch the instances.
+        type: str
+        required: false
+      partition_number:
+        description: The number of the partition the instance is in.
+        type: int
+        required: false
+      tenancy:
+        description: Type of tenancy to allow an instance to use. Default is shared tenancy. Dedicated tenancy will incur additional charges.
+        type: str
+        required: false
+        choices: ['dedicated', 'default']
+  license_specifications:
+    description:
+      - The license specifications to be used for the instance.
+    type: list
+    elements: dict
+    suboptions:
+      license_configuration_arn:
+        description: The Amazon Resource Name (ARN) of the license configuration.
+        type: str
+        required: true
   metadata_options:
     description:
       - Modify the metadata options for the instance.
@@ -532,6 +579,22 @@
     state: present
     tags:
       foo: bar
+
+# launches a mac instance with HostResourceGroupArn and LicenseSpecifications
+- name: start a mac instance with a host resource group and license specifications
+  amazon.aws.ec2_instance:
+    name: "mac-compute-instance"
+    key_name: "prod-ssh-key"
+    vpc_subnet_id: subnet-5ca1ab1e
+    instance_type: mac1.metal
+    security_group: default
+    placement:
+      host_resource_group_arn: arn:aws:resource-groups:us-east-1:123456789012:group/MyResourceGroup
+    license_specifications:
+      - license_configuration_arn: arn:aws:license-manager:us-east-1:123456789012:license-configuration:lic-0123456789
+    image_id: ami-123456
+    tags:
+      Environment: Testing
 """
 
 RETURN = r"""
@@ -658,6 +721,17 @@
             returned: always
             type: str
             sample: "2017-03-23T22:51:24+00:00"
+        licenses:
+            description: The license configurations for the instance.
+            returned: When license specifications are provided.
+            type: list
+            elements: dict
+            contains:
+                license_configuration_arn:
+                    description: The Amazon Resource Name (ARN) of the license configuration.
+                    returned: always
+                    type: str
+                    sample: arn:aws:license-manager:us-east-1:123456789012:license-configuration:lic-0123456789
         monitoring:
             description: The monitoring for the instance.
             returned: always
@@ -841,13 +915,36 @@
                     returned: always
                     type: str
                     sample: ap-southeast-2a
+                affinity:
+                    description: The affinity setting for the instance on the Dedicated Host.
+                    returned: When a placement group is specified.
+                    type: str
+                group_id:
+                    description: The ID of the placement group the instance is in (for cluster compute instances).
+                    returned: always
+                    type: str
+                    sample: "pg-01234566"
                 group_name:
                     description: The name of the placement group the instance is in (for cluster compute instances).
                     returned: always
                     type: str
-                    sample: ""
+                    sample: "my-placement-group"
+                host_id:
+                    description: The ID of the Dedicated Host on which the instance resides.
+                    returned: always
+                    type: str
+                host_resource_group_arn:
+                    description:  The ARN of the host resource group in which the instance is in.
+                    returned: always
+                    type: str
+                    sample: "arn:aws:resource-groups:us-east-1:123456789012:group/MyResourceGroup"
+                partition_number:
+                    description: The number of the partition the instance is in.
+                    returned: always
+                    type: int
+                    sample: 1
                 tenancy:
-                    description: The tenancy of the instance (if the instance is running in a VPC).
+                    description: Type of tenancy to allow an instance to use. Default is shared tenancy. Dedicated tenancy will incur additional charges.
                     returned: always
                     type: str
                     sample: default
@@ -1293,6 +1390,22 @@ def build_top_level_options(params):
             spec["Placement"]["GroupName"] = str(params.get("placement_group"))
         else:
             spec.setdefault("Placement", {"GroupName": str(params.get("placement_group"))})
+    if params.get("placement") is not None:
+        spec["Placement"] = {}
+        if params.get("placement").get("availability_zone") is not None:
+            spec["Placement"]["AvailabilityZone"] = params.get("placement").get("availability_zone")
+        if params.get("placement").get("affinity") is not None:
+            spec["Placement"]["Affinity"] = params.get("placement").get("affinity")
+        if params.get("placement").get("group_name") is not None:
+            spec["Placement"]["GroupName"] = params.get("placement").get("group_name")
+        if params.get("placement").get("host_id") is not None:
+            spec["Placement"]["HostId"] = params.get("placement").get("host_id")
+        if params.get("placement").get("host_resource_group_arn") is not None:
+            spec["Placement"]["HostResourceGroupArn"] = params.get("placement").get("host_resource_group_arn")
+        if params.get("placement").get("partition_number") is not None:
+            spec["Placement"]["PartitionNumber"] = params.get("placement").get("partition_number")
+        if params.get("placement").get("tenancy") is not None:
+            spec["Placement"]["Tenancy"] = params.get("placement").get("tenancy")
     if params.get("ebs_optimized") is not None:
         spec["EbsOptimized"] = params.get("ebs_optimized")
     if params.get("instance_initiated_shutdown_behavior"):
@@ -1323,7 +1436,12 @@ def build_top_level_options(params):
         )
         spec["MetadataOptions"]["HttpProtocolIpv6"] = params.get("metadata_options").get("http_protocol_ipv6")
         spec["MetadataOptions"]["InstanceMetadataTags"] = params.get("metadata_options").get("instance_metadata_tags")
-
+    if params.get("license_specifications"):
+        spec["LicenseSpecifications"] = []
+        for license_configuration in params.get("license_specifications"):
+            spec["LicenseSpecifications"].append(
+                {"LicenseConfigurationArn": license_configuration.get("license_configuration_arn")}
+            )
     return spec
 
 
@@ -2106,6 +2224,13 @@ def main():
         purge_tags=dict(type="bool", default=True),
         filters=dict(type="dict", default=None),
         launch_template=dict(type="dict"),
+        license_specifications=dict(
+            type="list",
+            elements="dict",
+            options=dict(
+                license_configuration_arn=dict(type="str", required=True),
+            ),
+        ),
         key_name=dict(type="str"),
         cpu_credit_specification=dict(type="str", choices=["standard", "unlimited"]),
         cpu_options=dict(
@@ -2117,6 +2242,18 @@ def main():
         ),
         tenancy=dict(type="str", choices=["dedicated", "default"]),
         placement_group=dict(type="str"),
+        placement=dict(
+            type="dict",
+            options=dict(
+                affinity=dict(type="str"),
+                availability_zone=dict(type="str"),
+                group_name=dict(type="str"),
+                host_id=dict(type="str"),
+                host_resource_group_arn=dict(type="str"),
+                partition_number=dict(type="int"),
+                tenancy=dict(type="str", choices=["dedicated", "default"]),
+            ),
+        ),
         instance_initiated_shutdown_behavior=dict(type="str", choices=["stop", "terminate"]),
         termination_protection=dict(type="bool"),
         hibernation_options=dict(type="bool", default=False),
@@ -2146,6 +2283,8 @@ def main():
             ["image_id", "image"],
             ["exact_count", "count"],
             ["exact_count", "instance_ids"],
+            ["tenancy", "placement"],
+            ["placement_group", "placement"],
         ],
         supports_check_mode=True,
     )
@@ -2159,6 +2298,20 @@ def main():
             if module.params.get("security_groups"):
                 module.fail_json(msg="Parameter network.interfaces can't be used with security_groups")
 
+    if module.params.get("placement_group"):
+        module.deprecate(
+            "The placement_group parameter has been deprecated, please use placement.group_name instead.",
+            date="2025-12-01",
+            collection_name="amazon.aws",
+        )
+
+    if module.params.get("tenancy"):
+        module.deprecate(
+            "The tenancy parameter has been deprecated, please use placement.tenancy instead.",
+            date="2025-12-01",
+            collection_name="amazon.aws",
+        )
+
     state = module.params.get("state")
 
     retry_decorator = AWSRetry.jittered_backoff(


Test patch:
diff --git a/tests/integration/targets/ec2_instance_license_specifications/aliases b/tests/integration/targets/ec2_instance_license_specifications/aliases
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_instance_license_specifications/aliases
@@ -0,0 +1,6 @@
+time=10m
+
+cloud/aws
+
+ec2_instance_info
+ec2_instance
diff --git a/tests/integration/targets/ec2_instance_license_specifications/defaults/main.yml b/tests/integration/targets/ec2_instance_license_specifications/defaults/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_instance_license_specifications/defaults/main.yml
@@ -0,0 +1,6 @@
+---
+# defaults file for ec2_instance_block_devices
+ec2_instance_type: 't3.micro'
+ec2_instance_tag_TestId: '{{ resource_prefix }}-license-specifications'
+ec2_host_resource_group_arn: 'arn:aws:resource-groups:{{ aws_region }}:123456789012:group/{{ resource_prefix }}-resource-group'
+ec2_license_configuration_arn: 'arn:aws:license-manager:{{ aws_region }}:123456789012:license-configuration:lic-0123456789'
diff --git a/tests/integration/targets/ec2_instance_license_specifications/meta/main.yml b/tests/integration/targets/ec2_instance_license_specifications/meta/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_instance_license_specifications/meta/main.yml
@@ -0,0 +1,6 @@
+# this just makes sure they're in the right place
+dependencies:
+- role: setup_ec2_facts
+- role: setup_ec2_instance_env
+  vars:
+    ec2_instance_test_name: license_specifications
diff --git a/tests/integration/targets/ec2_instance_license_specifications/tasks/main.yml b/tests/integration/targets/ec2_instance_license_specifications/tasks/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_instance_license_specifications/tasks/main.yml
@@ -0,0 +1,29 @@
+- module_defaults:
+    group/aws:
+      access_key: "{{ aws_access_key }}"
+      secret_key: "{{ aws_secret_key }}"
+      session_token: "{{ security_token | default(omit) }}"
+      region: "{{ aws_region }}"
+  block:
+  - name: "New instance with license specifications"
+    ec2_instance:
+      state: running
+      name: "{{ resource_prefix }}-test-ebs-vols"
+      image_id: "{{ ec2_ami_id }}"
+      vpc_subnet_id: "{{ testing_subnet_b.subnet.id }}"
+      placement:
+        host_resource_group_arn: "{{ ec2_host_resource_group_arn }}"
+      license_specifications:
+        - license_configuration_arn: "{{ ec2_license_configuration_arn }}"
+      tags:
+        TestId: "{{ ec2_instance_tag_TestId }}"
+      instance_type: "{{ ec2_instance_type }}"
+      wait: true
+    ignore_errors: true
+    register: instance_creation
+
+  - name: "Validate instance with license specifications"
+    assert:
+      that:
+        - instance_creation is failed
+        - '"An instance is associated with one or more unshared license configurations." in instance_creation.msg'
diff --git a/tests/integration/targets/ec2_instance_placement_options/aliases b/tests/integration/targets/ec2_instance_placement_options/aliases
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_instance_placement_options/aliases
@@ -0,0 +1,6 @@
+time=10m
+
+cloud/aws
+
+ec2_instance_info
+ec2_instance
diff --git a/tests/integration/targets/ec2_instance_placement_options/defaults/main.yml b/tests/integration/targets/ec2_instance_placement_options/defaults/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_instance_placement_options/defaults/main.yml
@@ -0,0 +1,6 @@
+---
+# defaults file for ec2_instance_block_devices
+ec2_instance_type: 't3.micro'
+ec2_instance_tag_TestId: '{{ resource_prefix }}-placement-group'
+ec2_tenancy: 'dedicated'
+ec2_placement_group_name: '{{ resource_prefix}}-placement-group'
\ No newline at end of file
diff --git a/tests/integration/targets/ec2_instance_placement_options/meta/main.yml b/tests/integration/targets/ec2_instance_placement_options/meta/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_instance_placement_options/meta/main.yml
@@ -0,0 +1,6 @@
+# this just makes sure they're in the right place
+dependencies:
+- role: setup_ec2_facts
+- role: setup_ec2_instance_env
+  vars:
+    ec2_instance_test_name: placement_options
diff --git a/tests/integration/targets/ec2_instance_placement_options/tasks/main.yml b/tests/integration/targets/ec2_instance_placement_options/tasks/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/ec2_instance_placement_options/tasks/main.yml
@@ -0,0 +1,80 @@
+- module_defaults:
+    group/aws:
+      access_key: "{{ aws_access_key }}"
+      secret_key: "{{ aws_secret_key }}"
+      session_token: "{{ security_token | default(omit) }}"
+      region: "{{ aws_region }}"
+  block:
+  - name: New placement group
+    community.aws.ec2_placement_group:
+      name: "{{ ec2_placement_group_name }}"
+      strategy: partition
+      partition_count: 1
+      state: present
+
+  - name: New instance with placement group name
+    ec2_instance:
+      state: running
+      name: "{{ resource_prefix }}-test-placement-group-name"
+      image_id: "{{ ec2_ami_id }}"
+      placement:
+        group_name: "{{ ec2_placement_group_name }}"
+      tags:
+        Name: "{{ resource_prefix }}-test-placement-group-name"
+        TestId: "{{ ec2_instance_tag_TestId }}"
+      security_group: default
+      instance_type: "{{ ec2_instance_type }}"
+      wait: true
+    ignore_errors: true
+    register: instance_creation
+
+  - name: Gather ec2 facts to check placement group options
+    ec2_instance_info:
+      filters:
+        "tag:Name": "{{ resource_prefix }}-test-placement-group-name"
+        "tag:TestId": "{{ ec2_instance_tag_TestId }}"
+        "instance-state-name": "running"
+    ignore_errors: true
+    register: instance_facts
+
+  - name: Validate instance with placement group name
+    assert:
+      that:
+        - instance_creation is success
+        - instance_creation is changed
+        - instance_facts.instances[0].placement.group_name == ec2_placement_group_name
+        # - instance_creation is failed
+        # - '"You are not authorized to perform this operation." in instance_creation.msg'
+
+  - name: New instance with dedicated tenancy
+    ec2_instance:
+      state: running
+      name: "{{ resource_prefix }}-test-dedicated-tenancy"
+      image_id: "{{ ec2_ami_id }}"
+      placement:
+        tenancy: "{{ ec2_tenancy }}"
+      tags:
+        TestId: "{{ ec2_instance_tag_TestId }}"
+      security_group: default
+      instance_type: "{{ ec2_instance_type }}"
+      wait: true
+    ignore_errors: true
+    register: instance_creation
+
+  - name: Gather ec2 facts to check placement tenancy
+    ec2_instance_info:
+      filters:
+        "tag:Name": "{{ resource_prefix }}-test-dedicated-tenancy"
+        "tag:TestId": "{{ ec2_instance_tag_TestId }}"
+        "instance-state-name": "running"
+    ignore_errors: true
+    register: instance_facts
+
+  - name: Validate instance with dedicated tenancy
+    assert:
+      that:
+        - instance_creation is success
+        - instance_creation is changed
+        - instance_facts.instances[0].placement.tenancy == ec2_tenancy
+        # - instance_creation is failed
+        # - '"You are not authorized to perform this operation." in instance_creation.msg'