Application & Environment Groups
On top of the platform and OS tiers, both clouds group hosts by what they run (app_*) and which environment they run in (<env>_<cloud>_platform, <env>_<cloud>_app_<app>). These groups are derived from Terraform VM tags — the application and environment tags — so no host lists are maintained by hand: tag a VM and it lands in the right groups automatically, and it drops out when the instance goes away.
These groups live in the source inventory, not a separate constructed plugin
This grouping used to live in a standalone ansible.builtin.constructed inventory (inventory.constructed.yml on AWS, inventory.azure.constructed.yml on Azure). Both clouds now build the app and environment tiers directly in their source inventory plugin — inventory.aws_ec2.yml (amazon.aws.aws_ec2) and inventory.azure_rm.yml (azure.azcollection.azure_rm) — using the same keyed_groups/compose machinery. The constructed files were removed and are no longer listed in ansible.cfg / ansible-azure.cfg.
The application tier — app_<tag>
A keyed_groups entry keys the application VM tag into a group named app_<tag>:
# inventory.aws_ec2.yml / inventory.azure_rm.yml (identical block)
keyed_groups:
- key: tags.application
default_value: unclassified
prefix: "app"
separator: "_"
compose:
application: tags.application | default('unclassified')
A VM tagged application: kuiper lands in app_kuiper; application: systempulse → app_systempulse, and so on (app_nginx, app_odb, app_icfg, app_hsw, app_ahsw, …). Tagging a new VM is all it takes to group it — no inventory edit needed. These app_* groups are shared across clouds and form the application tier of the group taxonomy.
Untagged vs. empty-tagged hosts
default_value: unclassified only fires when the application tag is present but empty — such a host joins app_unclassified. A host with no application tag at all lands in no app_ group (the key errors and, under strict: false, the host is skipped). The compose variable application still resolves to unclassified for those hosts because it uses | default(...).
The same tag is surfaced as the per-host compose variable application — the raw tag value, not the app_-prefixed group name. On Azure it is the single input everything AD-related derives from: group_vars/platform_azure/vars.yml templates computer_ou (OU={{ application }},OU=Servers,OU={{ ad_ou_root }}) for playbooks/windows-join-domain.yml and playbooks/ensure-computer-ou.yml, and playbooks/create-ad-ous.yml enumerates the distinct application values to build the OU tree.
The environment tiers — <env>_<cloud>_platform and <env>_<cloud>_app_<app>
Two more keyed_groups entries combine the environment tag (with - mapped to _) with the cloud and, for one of them, the application:
keyed_groups:
# <env>_<cloud>_app_<application> e.g. dev_aws_app_kuiper, ire_copier_azure_app_kuiper
- key: >-
(tags.environment | default('unknown') | regex_replace('-', '_'))
~ '_aws_app_' ~ (tags.application | default('unclassified')) # '_azure_app_' on Azure
prefix: ""
separator: ""
# <env>_<cloud>_platform e.g. dev_aws_platform, ire_copier_azure_platform
- key: >-
(tags.environment | default('unknown') | regex_replace('-', '_'))
~ '_aws_platform' # '_azure_platform' on Azure
prefix: ""
separator: ""
<env>_<cloud>_platformholds what every host in one environment shares — the environment's global secrets ingroup_vars/<env>_<cloud>_platform/vault.yml(see Secret Management) — while settings common to all hosts of that cloud regardless of environment stay ingroup_vars/platform_*/.<env>_<cloud>_app_<application>scopes one application to one environment. Because theapp_*groups are shared across clouds, cloud- or environment-specific app config — Azure SQL connection strings, AADDS account names — lives in the scoped group'sgroup_vars(e.g.group_vars/ire_copier_azure_app_kuiper/), while cloud-agnostic app config stays ingroup_vars/app_<tag>/.
Unlike the plain app_ tier, these keys are computed strings with defaults baked in, so every host always lands in one of each — even a host with no application tag (it becomes <env>_<cloud>_app_unclassified).
Precedence between all these tiers — and the one pinning that <env>_<cloud>_platform requires — is covered in Group priority.
Assigning variables to a group
Once a group exists, Ansible automatically loads group_vars/<group_name>/vars.yml (and vault.yml) for every host in that group. This is the primary reason to define a group: shared variables.
Example — Perimeter Auth
Every host tagged application: nginx runs Perimeter Auth and lands in app_nginx, so values that hold for all of them live in one shared file rather than being duplicated per host:
# group_vars/app_nginx/vars.yml — every nginx host, any cloud or environment
certificate_authority_linux_key_path: /etc/ssl/private/nginx-epic.key
certificate_authority_linux_cert_path: /etc/ssl/certs/nginx-epic.crt
What that file must not hold is anything that differs between environments. The Epic application topology a proxy fronts — its public hostnames, the backends it proxies to, the Interconnect instance names — is environment-specific, so it belongs in the scoped group, which wins on any collision:
# group_vars/ire_copier_azure_app_nginx/vars.yml — only the Azure IRE nginx hosts
perimeter_auth_pauth_root_domain: sapphirehealth.org
perimeter_auth_pauth_app_servers:
- server_names: "{{ nginx_public_hostnames }}"
root_redirect_path: HSWeb_ODRO
environments:
- name: IRE
ic_path: Interconnect-PAuth
ic_fqdn: "{{ icfg_internal_hostname }}"
hsweb:
path: HSWeb_ODRO
backend_fqdn: "{{ hsw_internal_hostname }}"
auth_server_url: "https://{{ authhsw_public_hostname }}/HSWeb_ODRO"
The backend names here come from the environment's <env>_<cloud>_platform tier, so the app tier states topology while the platform tier owns the names — see group_vars/ire_copier_azure_platform.
Note that a dict or list variable replaces rather than merges, so a scoped group that needs to change one entry restates the whole value. perimeter_auth_firewall_zones is defined in both tiers for exactly this reason: the AWS source ranges in app_nginx mean nothing on Azure, so the Azure group states its own zones in full.
Any host-specific overrides (e.g. different SANs for the TLS certificate) go in the individual host_vars/<hostname>.yml file and take precedence over the group vars.
Using groups as inventory targets
These groups work as --limit targets and in playbook hosts: directives just like any other Ansible group:
# Run against all perimeter auth (nginx) hosts
ansible-playbook --limit=app_nginx playbooks/deploy-perimeter-auth.yml --tags install --become
# Run against all Kuiper hosts
ansible-playbook --limit=app_kuiper playbooks/deploy-kuiper.yml --tags install --become
# Run against just the Kuiper host in the AWS dev environment
ansible-playbook --limit=dev_aws_app_kuiper playbooks/deploy-kuiper.yml --tags install --become
Using groups as gMSA membership sources
The gMSA playbook uses inventory groups to populate AD security group membership via the members_group field. Because the groups reflect live cloud inventory, the AD group membership stays in sync as hosts are added or replaced — no manual list to maintain.
# extra_vars/gmsas.yml
gmsa_definitions:
- name: kuiper-gmsa
group: KuiperAllowedHosts
members_group: app_kuiper # all hosts in the app_kuiper group
See the gMSA Usage Guide for full details.
Adding a new group
Because membership is tag-driven, adding a group is a Terraform change, not an inventory edit:
-
Set the VM's
application(and, for the environment tiers,environment) tag in Terraform, then apply so the tag lands on the instance. A VM taggedapplication: myapp,environment: devproducesapp_myapp,dev_aws_app_myapp, anddev_aws_platformautomatically. -
Create
group_vars/<group_name>/vars.ymlwith the variables the group should share. -
If the new group is an
<env>_<cloud>_platformgroup that carries its owngroup_vars, pin its priority ininventory.group_priority.yml(see Group priority). -
Verify:
Group name restrictions
Ansible group names must be valid Python identifiers — letters, digits, and underscores only. Hyphens are not allowed, which is why the environment tag has - mapped to _ in the group keys (ire-copier → ire_copier). A group_vars/my-app/ directory will not be loaded for a group named my_app.