This toolset installs and configures Foreman on a Rocky 9 host using
ansible-foreman-create (Python CLI) backed by Ansible playbooks.
You need: ansible-foreman-create, the ansible/ directory, and a Python venv.
The venv/ directory is not portable — recreate it on the control node:
python3 -m venv venv
source venv/bin/activate
pip install ansible-core PyYAML| Package | Why |
|---|---|
ansible-core |
Ansible engine and all ansible.builtin modules |
PyYAML |
Required by ansible-core internally; also used by local Jinja2 | from_yaml filters |
Note: ansible-core declares PyYAML as a dependency and will pull it in
automatically, but listing it explicitly prevents surprises if the version
constraint ever changes.
The foreman_configure role installs these on the Foreman host during the
playbook run. They are only needed while the playbook is running — Foreman
itself (a Ruby on Rails application) does not use them at runtime.
| Package | Installed via | Why |
|---|---|---|
apypie |
pip (PyPI) |
HTTP client used by all theforeman.foreman collection modules; no RPM available in Foreman 3.x repos for EL9 |
python3-pyyaml |
dnf (standard Rocky 9 repos) |
Required by theforeman.foreman.partition_table and other collection modules |
Collections are stored in ~/.ansible/collections/, not inside the venv.
Install with:
venv/bin/ansible-galaxy collection install -r ansible/requirements.ymlansible-foreman-create also installs a hammer wrapper into venv/bin/ after a
successful run. The wrapper SSHes to the Foreman host and runs the real hammer
CLI there. If you recreate the venv you can reinstall it manually:
cat > venv/bin/hammer << 'EOF'
#!/usr/bin/env bash
FOREMAN_HOST=$(python3 << 'PYEOF'
import re, os
f = os.path.expanduser('~/.hammer/cli.modules.d/foreman.yml')
c = open(f).read()
m = re.search(r":host:\s+'https?://([^/']+)/?'", c)
print(m.group(1) if m else '')
PYEOF
)
if [[ -z "$FOREMAN_HOST" ]]; then
echo "hammer: cannot determine Foreman host from ~/.hammer/cli.modules.d/foreman.yml" >&2
exit 1
fi
CMD="hammer"
for arg in "$@"; do
CMD="$CMD $(printf '%q' "$arg")"
done
exec ssh -q -o BatchMode=yes root@"$FOREMAN_HOST" "$CMD"
EOF
chmod +x venv/bin/hammerPasswordless SSH to root on the target host must be in place before running.
Set these shell variables to match your environment before running any commands in this section. All subsequent commands reference them.
FOREMAN_HOST=foreman.example.com # FQDN of the target Foreman host
DNS_DOMAIN=subdomain.example.com # DNS domain for managed hosts
SUBNET_CIDR=192.168.200.0/24 # Managed subnet (CIDR notation)
DHCP_IFACE=eth1 # Network interface on Foreman host for DHCP
UPSTREAM_DNS=8.8.8.8,8.8.4.4 # Comma-separated upstream DNS forwarder IPs
SSL_CERT=/path/to/wildcard.pem # SSL certificate file (PEM bundle: server cert + CA chain)
SSL_KEY=/path/to/wildcard.key # SSL private key fileOptional — override subnet defaults:
SUBNET_GATEWAY=192.168.200.254 # Gateway IP (default: last usable host IP in subnet)
SUBNET_PRIMARY_DNS=192.168.200.1 # Primary DNS IP (default: Foreman host IP on subnet)
SUBNET_SECONDARY_DNS= # Secondary DNS IP (default: none)source venv/bin/activate
./ansible-foreman-create \
--host "$FOREMAN_HOST" \
--no-new-name \
--dns "$DNS_DOMAIN" \
--subnet "$SUBNET_CIDR" \
--dhcp-interface "$DHCP_IFACE" \
--upstream-dns "$UPSTREAM_DNS" \
--ssl-cert "$SSL_CERT" \
--ssl-key "$SSL_KEY"Pass the optional flags to override subnet defaults:
./ansible-foreman-create \
--host "$FOREMAN_HOST" \
--no-new-name \
--dns "$DNS_DOMAIN" \
--subnet "$SUBNET_CIDR" \
--subnet-gateway "$SUBNET_GATEWAY" \
--subnet-primary-dns "$SUBNET_PRIMARY_DNS" \
--subnet-secondary-dns "$SUBNET_SECONDARY_DNS" \
--dhcp-interface "$DHCP_IFACE" \
--upstream-dns "$UPSTREAM_DNS" \
--ssl-cert "$SSL_CERT" \
--ssl-key "$SSL_KEY"ansible-foreman-create derives all subnet values (network address, mask, reverse
DNS zone, first-host IP, gateway, DNS servers, DHCP range) from --subnet
automatically. Override individual values with the optional flags above.
- Hammer CLI config is written to
~/.hammer/cli.modules.d/foreman.yml $DHCP_IFACEis configured with a static IP (first host address in$SUBNET_CIDR)- Configure the switch port for the correct VLAN before provisioning managed hosts
The playbook roles are tagged so they can be run independently with --tags.
This is useful for re-applying a specific piece of configuration without
running the full install (which re-runs foreman-installer and takes 5–10 min).
Define these once in your shell session. All role commands below reference them.
Derived values (network address, mask, prefix, reverse zone) must be filled in
to match the chosen subnet — ansible-foreman-create computes these automatically
during a full install, but manual role runs require them spelled out.
# Adjust all values to match your environment
FOREMAN_HOST=foreman.example.com
DNS_DOMAIN=subdomain.example.com
SUBNET_CIDR=192.168.200.0/24
SUBNET_NET=192.168.200.0
SUBNET_MASK=255.255.255.0
SUBNET_PREFIX=24
SUBNET_GATEWAY=192.168.200.254 # last usable host IP; override if needed
SUBNET_PRIMARY_DNS=192.168.200.1 # Foreman host IP; override if needed
SUBNET_SECONDARY_DNS= # leave blank for no secondary DNS
DHCP_IP=192.168.200.1
DHCP_RANGE_START=192.168.200.2 # first IP after Foreman host
DHCP_RANGE_END=192.168.200.253 # last IP before gateway
DHCP_DNS_SERVERS="192.168.200.1" # primary only; append ", <ip>" for secondary
DHCP_IFACE=eth1
UPSTREAM_DNS_LIST='["8.8.8.8","8.8.4.4"]' # JSON list of upstream DNS forwarder IPs
REVERSE_ZONE=200.168.192.in-addr.arpa
SSL_CERT=/path/to/wildcard.pem
SSL_KEY=/path/to/wildcard.keyCreate the inventory file:
cat > /tmp/foreman_inv.ini << EOF
[foreman]
${FOREMAN_HOST} ansible_user=root
EOFBuild the extra-vars JSON (shell variables are expanded into the heredoc):
EVARS=$(python3 << PYEOF
import json
d = {
"foreman_fqdn": "$FOREMAN_HOST",
"new_fqdn": "",
"dns_domain": "$DNS_DOMAIN",
"subnet_cidr": "$SUBNET_CIDR",
"subnet_network": "$SUBNET_NET",
"subnet_mask": "$SUBNET_MASK",
"subnet_gateway": "$SUBNET_GATEWAY",
"subnet_primary_dns": "$SUBNET_PRIMARY_DNS",
"dhcp_interface": "$DHCP_IFACE",
"dhcp_interface_ip": "$DHCP_IP",
"dhcp_prefix": "$SUBNET_PREFIX",
"dhcp_range_start": "$DHCP_RANGE_START",
"dhcp_range_end": "$DHCP_RANGE_END",
"dhcp_dns_servers": "$DHCP_DNS_SERVERS",
"upstream_dns_servers": $UPSTREAM_DNS_LIST,
"dns_reverse_zone": "$REVERSE_ZONE",
"ssl_cert_src": "$SSL_CERT",
"ssl_key_src": "$SSL_KEY",
}
if "$SUBNET_SECONDARY_DNS":
d["subnet_secondary_dns"] = "$SUBNET_SECONDARY_DNS"
print(json.dumps(d))
PYEOF
)All commands below assume /tmp/foreman_inv.ini and $EVARS are set as described above.
venv/bin/ansible-playbook -i /tmp/foreman_inv.ini ansible/site.yml [--tags <tag>] --extra-vars "$EVARS"| Tag | Scope |
|---|---|
| (none) | Full install: runs all four roles in order (firewall, install, distro, configure) |
foreman_firewall |
Open required ports in iptables; reorder rules if needed |
foreman_install |
Full Foreman installation: hostname, SSL, sysctl, foreman-installer, named, DHCP, hammer config |
foreman_distro |
Scan /mnt/iso, mount ISOs under /srv/distro, write Apache distro config |
foreman_configure |
Domain, subnet, OS entries, templates, partition tables, hostgroups, global settings |
foreman_sysctl |
Kernel sysctl parameters only (subset of foreman_install) |
foreman_named |
named/DNS options only (subset of foreman_install) |
foreman_dhcp |
DHCP subnet options only (subset of foreman_install) |
Detects whether the required ports are open and positioned before the default REJECT rule. Removes and re-inserts all rules if any are missing or out of order. Idempotent — prints "unchanged" and makes no changes if already correct.
venv/bin/ansible-playbook -i /tmp/foreman_inv.ini ansible/site.yml \
--tags foreman_firewall --extra-vars "$EVARS"To inspect the current iptables INPUT chain on the host directly:
ssh root@"$FOREMAN_HOST" 'iptables -L INPUT --line-numbers -n'Scans /mnt/iso for ISO files, mounts any found under /srv/distro/<Distro>/<version>/<arch>,
adds /etc/fstab entries, and writes /etc/httpd/conf.d/distro.conf.
Idempotent — already-mounted ISOs and existing fstab entries are left unchanged.
Run this after dropping new ISOs into /mnt/iso to pick them up without a full
install.
venv/bin/ansible-playbook -i /tmp/foreman_inv.ini ansible/site.yml \
--tags foreman_distro --extra-vars "$EVARS"To inspect what is currently mounted under /srv/distro:
ssh root@"$FOREMAN_HOST" 'findmnt -t iso9660 -o SOURCE,TARGET'Re-runs the theforeman.foreman API calls that create the DNS domain, subnet,
and operating system entries in Foreman. Safe to run repeatedly — all modules
are idempotent. Useful if any of these were changed or deleted in the UI, or
after mounting new ISOs (OS entries are derived from currently mounted ISOs).
venv/bin/ansible-playbook -i /tmp/foreman_inv.ini ansible/site.yml \
--tags foreman_configure --extra-vars "$EVARS"To see what operating systems Foreman currently has defined:
ssh root@"$FOREMAN_HOST" 'hammer os list'Reads any content files in the drop-in directories and pushes them to Foreman.
Idempotent — existing items with unchanged content are not re-uploaded.
All of the following are picked up in a single foreman_configure run:
venv/bin/ansible-playbook -i /tmp/foreman_inv.ini ansible/site.yml \
--tags foreman_configure --extra-vars "$EVARS"| Directory | Foreman type |
|---|---|
ansible/partition_tables/ |
Partition table |
ansible/provisioning_templates/ |
Provisioning template (kind: provision) |
ansible/pxelinux_templates/ |
Provisioning template (kind: PXELinux) |
ansible/pxegrub2_templates/ |
Provisioning template (kind: PXEGrub2) |
ansible/snippets/ |
Provisioning template (kind: snippet) |
To inspect what is currently in Foreman:
ssh root@"$FOREMAN_HOST" 'hammer partition-table list'
ssh root@"$FOREMAN_HOST" 'hammer template list'Runs all roles in order: firewall → install → configure.
foreman-installer is idempotent but takes 5–10 minutes even when nothing
changes. Prefer individual role tags for targeted fixes.
venv/bin/ansible-playbook -i /tmp/foreman_inv.ini ansible/site.yml \
--extra-vars "$EVARS"All drop-in directories follow the same conventions:
- Content files: no file extension; the filename becomes the name in Foreman.
- OS associations: an optional
os_associations.ymlin the same directory. - Git: content files and
*.ymlsidecar files are gitignored — never committed.*.examplefiles are committed and serve as templates to copy from.
Each directory ships with an os_associations.yml.example showing the expected
format. Copy it to os_associations.yml and edit as needed. Similarly,
pxelinux_templates/ and pxegrub2_templates/ include a local-boot.yml.example.
Run --tags foreman_configure to push any newly added files to Foreman.
| Directory | Foreman type | Module |
|---|---|---|
ansible/partition_tables/ |
Partition table | theforeman.foreman.partition_table |
ansible/provisioning_templates/ |
Provision template | theforeman.foreman.provisioning_template (kind: provision) |
ansible/pxelinux_templates/ |
PXELinux template | theforeman.foreman.provisioning_template (kind: PXELinux) |
ansible/pxegrub2_templates/ |
PXEGrub2 template | theforeman.foreman.provisioning_template (kind: PXEGrub2) |
ansible/snippets/ |
Snippet | theforeman.foreman.provisioning_template (kind: snippet) |
Create a file in ansible/partition_tables/ with no extension (filename = Foreman name).
Content must be a valid Foreman partition table (Ruby ERB):
<%#
kind: ptable
name: RHEL9_RHEL10
%>
zerombr
clearpart --all --initlabel
autopart --type lvm
Create a file in the appropriate directory. The content must be a valid Foreman
ERB template. Foreman reads the kind: metadata from the ERB header comment,
but the module's kind parameter (set by the directory) takes precedence:
<%#
kind: provision
name: My Kickstart
%>
... kickstart content ...
For PXELinux templates (ansible/pxelinux_templates/):
<%#
kind: PXELinux
name: My PXELinux
%>
DEFAULT linux
...
For snippets (ansible/snippets/):
<%#
kind: snippet
name: My Snippet
%>
... reusable snippet content ...
Each drop-in directory supports an optional os_associations.yml that maps
template/table names to the operating system titles as they appear in Foreman.
The file is also gitignored and need not exist until OS associations are required.
# ansible/provisioning_templates/os_associations.yml
My Kickstart:
- "Red Hat Enterprise Linux 9.4"
- "Red Hat Enterprise Linux 10.0"
- "Rocky Linux 9.1"The same format applies to partition_tables/, provisioning_templates/, pxelinux_templates/, and pxegrub2_templates/. Snippets are included by other templates and do not have OS associations — os_associations.yml is not supported in snippets/.
OS titles must match exactly — check with:
ssh root@"$FOREMAN_HOST" 'hammer os list'The OS association task queries each item's current OS list and adds the specified OSes without removing existing ones. Idempotent. If a template name or OS title does not exist in Foreman, the task prints a warning and skips that entry without failing the playbook run.
provisioning_templates/, pxelinux_templates/, and pxegrub2_templates/ each
support an optional OS_Defaults: section in os_associations.yml. This sets
the per-OS default template of that kind — the template Foreman selects
automatically when building or PXE-booting a host with that OS and no per-host
override is configured.
# ansible/pxelinux_templates/os_associations.yml
My PXELinux Template:
- "RHEL 9.4"
- "Rocky 9.1"
OS_Defaults:
"RHEL 9.4":
template: "My PXELinux Template"
"Rocky 9.1":
template: "My PXELinux Template"Rules:
OS_Defaultsis optional. Omitting it leaves existing Foreman defaults unchanged.- The
template:value must be a template that is already associated with that OS (either listed in the same file or previously associated by another means). If it is not associated, a warning is printed and the default is not set. - If the OS title or template name is not found in Foreman, a warning is printed and that entry is skipped. The playbook does not fail.
- Idempotent — if the default is already set to the named template, no change is made.
To inspect current OS default templates:
ssh root@"$FOREMAN_HOST" 'hammer os list'
ssh root@"$FOREMAN_HOST" 'hammer os info --title "RHEL 9.4"'Hostgroups are defined in ansible/hostgroups.yml (gitignored — may contain
root passwords). Copy the example and edit:
cp ansible/hostgroups.yml.example ansible/hostgroups.ymlThe file uses a foreman_hostgroups: top-level key containing a list of
hostgroup definitions. It is loaded with Ansible include_vars, so
Ansible Vault-encrypted strings work for sensitive values like root_pass.
Field reference:
| Field | Required | Description |
|---|---|---|
name |
yes | Hostgroup leaf name as it will appear in Foreman |
parent |
no | Full name of an existing parent hostgroup |
description |
no | Free-text description |
domain |
no | DNS domain name (must exist in Foreman) |
subnet |
no | IPv4 subnet name (must exist in Foreman) |
architecture |
no | CPU architecture, e.g. x86_64 |
operatingsystem |
no | OS title — run hammer os list for exact values |
medium |
no | Installation media name (must exist in Foreman) |
ptable |
no | Partition table name (must exist in Foreman) |
pxe_loader |
no | PXELinux BIOS, Grub2 UEFI, Grub2 UEFI SecureBoot, etc. |
root_pass |
no | Root password for provisioned hosts |
Inheritance: A child hostgroup lists only the fields it needs to override. Any field omitted in the child is inherited from the parent. For example, to have one hostgroup for BIOS boot and another for UEFI with everything else the same:
foreman_hostgroups:
- name: "RHEL BIOS"
domain: "subdomain.example.com"
subnet: "192.168.200.0/24"
architecture: "x86_64"
operatingsystem: "RHEL 9.4"
medium: "RHEL Local"
ptable: "My Partition Table"
pxe_loader: "PXELinux BIOS"
root_pass: "changeme"
- name: "RHEL UEFI"
parent: "RHEL BIOS"
pxe_loader: "Grub2 UEFI"Important: list parent hostgroups before any children that reference them — entries are processed in order, and a child whose parent does not yet exist in Foreman will fail.
All hostgroups are associated with the default organization and location,
controlled by the foreman_default_organization and foreman_default_location
role variables (both default to "Default Organization" / "Default Location").
Override via --extra-vars if your Foreman uses different names.
To run hostgroup configuration independently:
venv/bin/ansible-playbook -i /tmp/foreman_inv.ini ansible/site.yml \
--tags foreman_configure --extra-vars "$EVARS"To inspect current hostgroups:
ssh root@"$FOREMAN_HOST" 'hammer hostgroup list'
ssh root@"$FOREMAN_HOST" 'hammer hostgroup info --name "RHEL BIOS"'Foreman has two global provisioning settings that control which template is used when a managed host should boot locally (i.e. from its own disk rather than PXE):
| Foreman setting | API name |
|---|---|
| Local boot PXELinux template | local_boot_PXELinux |
| Local boot PXEGrub2 template | local_boot_PXEGrub2 |
To configure these, create local-boot.yml in the relevant template directory:
# ansible/pxelinux_templates/local-boot.yml
template: "PXELinux default local boot"# ansible/pxegrub2_templates/local-boot.yml
template: "PXEGrub2 default local boot"The template: value must be the exact name of a template that already exists
in Foreman — either one of Foreman's built-in templates or a custom template
that has been uploaded from the same drop-in directory. The task verifies the
template exists and has the correct kind before applying the setting, and prints
a warning (without failing) if the named template is not found.
To check current values:
ssh root@"$FOREMAN_HOST" 'hammer settings list --search "local_boot"'Both local-boot.yml files are gitignored (covered by the * exclusion in
.gitignore) and are optional — omitting the file leaves the Foreman setting
unchanged.
ansible/
partition_tables/ ← content files gitignored; *.example committed
.gitkeep
os_associations.yml.example ← copy to os_associations.yml and edit
RHEL9_RHEL10 ← content file (no extension) — gitignored
os_associations.yml ← optional; maps names → OS list — gitignored
provisioning_templates/ ← kind: provision
.gitkeep
os_associations.yml.example
My_Kickstart ← content file — gitignored
os_associations.yml ← optional — gitignored
pxelinux_templates/ ← kind: PXELinux
.gitkeep
local-boot.yml.example ← copy to local-boot.yml and edit
os_associations.yml.example
local-boot.yml ← optional; sets "Local boot PXELinux template" — gitignored
os_associations.yml ← optional — gitignored
pxegrub2_templates/ ← kind: PXEGrub2
.gitkeep
local-boot.yml.example
os_associations.yml.example
local-boot.yml ← optional; sets "Local boot PXEGrub2 template" — gitignored
os_associations.yml ← optional — gitignored
snippets/ ← kind: snippet; no OS associations
.gitkeep