Files
pxe-server/playbook/pxe_server_setup.yml
cproudlock 03ed694671 pxe_server_setup: close three playbook gaps from 2026-04-22 session
1. Deploy gea-standard / gea-engineer FlatUnattendW10.xml
   The playbook only copied the shopfloor variant before; standard and
   engineer's unattend was hand-staged on the running servers. New task
   loops the repo's playbook/FlatUnattendW10.xml into Deploy/ for each
   entry in standard_types (new var covering gea-standard, gea-engineer,
   ge-standard, ge-engineer). force: yes because repo drift vs deployed
   copy is what produced the Win10/Win11 search-cleanup regression
   earlier this session (d49f516).

2. Deploy Oracle Client 11.2 preinstall payload
   preinstall.json now leads with Oracle 11.2 (commit 3a29784). The CMD
   wrapper is tracked in the repo at playbook/preinstall/oracle/; the
   686 MB Oracle_OracleDatabase_11r2_V03.zip is too large to commit and
   rides on USB under oracle/ alongside BIOS exes. Three tasks:
   mkdir staging dir, copy CMD from usb_mount, copy zip from usb_root
   with a soft-fail + warning if absent.

3. No change needed for sync-preinstall.sh — Oracle 10.2.0.3 flat
   installer was already dropped in 9235d19.

YAML lints clean. Fresh server built from this commit will bring up
Blancco-agnostic imaging paths correctly; Blancco-specific gaps
(grubx64.efi native-vs-slim, narrow kexec-initrd driver tree,
narrow blancco-init.sh) are still deferred per earlier "option B"
decision and remain server-side-pinned only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:59:08 -04:00

1003 lines
34 KiB
YAML

---
- name: PXE Server Setup (Ubuntu with dnsmasq)
hosts: localhost
connection: local
become: yes
gather_facts: yes
pre_tasks:
- name: "Verify required packages are installed (pre-installed from offline .debs)"
command: dpkg -s {{ item }}
loop:
- dnsmasq
- apache2
- samba
- unzip
- ufw
- cron
- ansible
- wimtools
- conntrack
register: pkg_check
failed_when: false
changed_when: false
- name: "Warn about missing packages"
debug:
msg: "WARNING: {{ item.item }} is not installed! Install offline .debs first."
loop: "{{ pkg_check.results }}"
when: item.rc != 0
vars:
tftp_dir: "/srv/tftp"
web_root: "/var/www/html"
samba_share: "/srv/samba/winpeapps"
usb_mount: "/mnt/usb/playbook" # playbook location on USB
usb_root: "/mnt/usb" # CIDATA partition root
image_types:
- gea-standard
- gea-engineer
- gea-shopfloor
- ge-standard
- ge-engineer
- ge-shopfloor-lockdown
- ge-shopfloor-mce
shopfloor_types:
- gea-shopfloor
# Image variants that share the generic Win10/Win11 unattend
# (gea-standard is now Win11, gea-engineer is Win10 — same unattend file
# drives both because the RunSynchronous entries are OS-version-agnostic).
standard_types:
- gea-standard
- gea-engineer
- ge-standard
- ge-engineer
deploy_subdirs:
- Applications
- Control
- "Operating Systems"
- "Out-of-box Drivers"
- Packages
- Tools
tasks:
- name: "Gather minimal network facts"
ansible.builtin.setup:
filter:
- ansible_interfaces
- ansible_default_ipv4
- name: "Bring up all ethernet-like interfaces"
command: ip link set dev {{ item }} up
loop: "{{ ansible_interfaces | select('match','^e(th|n)') | list }}"
ignore_errors: yes
- name: "Find interface with 10.9.100.1 already configured"
set_fact:
preconfigured_iface: >-
{{ ansible_interfaces
| select('match','^e(th|n)')
| map('regex_replace','^(.*)$','ansible_\1')
| map('extract', hostvars[inventory_hostname])
| selectattr('ipv4','defined')
| selectattr('ipv4.address','equalto','10.9.100.1')
| map(attribute='device')
| list
| first
| default('') }}
ignore_errors: yes
- name: "Determine PXE interface"
set_fact:
pxe_iface: >-
{{ preconfigured_iface | default('',true)
or (ansible_interfaces
| select('match','^e(th|n)')
| reject('equalto','lo')
| reject('equalto', ansible_default_ipv4.interface | default(''))
| list
)
| first
| default(ansible_default_ipv4.interface | default(
ansible_interfaces | select('match','^e(th|n)') | first | default('eth0')
)) }}
- name: "Debug: final pxe_iface choice"
debug:
msg: "Using {{ pxe_iface }} for DHCP/TFTP"
- name: "Expand root partition and filesystem to use full disk"
args:
executable: /bin/bash
shell: |
# Find the root LV device
ROOT_DEV=$(findmnt -n -o SOURCE /)
ROOT_DISK=$(lsblk -n -o PKNAME $(readlink -f "$ROOT_DEV") | tail -1)
if [ -z "$ROOT_DISK" ]; then
echo "Could not determine root disk, skipping"
exit 0
fi
# Find the partition number for the LVM PV
PV_PART=$(pvs --noheadings -o pv_name | tr -d ' ' | head -1)
if [ -z "$PV_PART" ]; then
echo "No LVM PV found, skipping"
exit 0
fi
PART_NUM=$(echo "$PV_PART" | grep -o '[0-9]*$')
echo "Expanding /dev/${ROOT_DISK} partition ${PART_NUM} (${PV_PART})..."
growpart "/dev/${ROOT_DISK}" "${PART_NUM}" 2>&1 || true
pvresize "$PV_PART" 2>&1
lvextend -l +100%FREE "$ROOT_DEV" 2>&1 || true
resize2fs "$ROOT_DEV" 2>&1
echo "Disk: $(df -h / | tail -1)"
register: disk_expand
changed_when: "'CHANGED' in disk_expand.stdout or 'resized' in disk_expand.stdout"
- name: "Deploy dnsmasq dhcp-script for per-lease state cleanup"
copy:
src: "{{ usb_mount }}/pxe-server-helpers/pxe-dhcp-hook.sh"
dest: /usr/local/sbin/pxe-dhcp-hook.sh
mode: '0755'
- name: "Configure dnsmasq for DHCP and TFTP"
copy:
dest: /etc/dnsmasq.conf
backup: yes
content: |
port=0
interface={{ pxe_iface }}
bind-interfaces
dhcp-range=10.9.100.10,10.9.100.100,12h
dhcp-option=3,10.9.100.1
dhcp-option=6,8.8.8.8
enable-tftp
tftp-root={{ tftp_dir }}
dhcp-boot=ipxe.efi
log-dhcp
# Per-lease state cleanup: flush conntrack + port-445 sockets for
# the client IP on add/del. Prevents "System error 53" when a PXE
# client re-images the same machine and hits a stale SMB session.
# Script runs as root by default (dnsmasq --dhcp-scriptuser default).
dhcp-script=/usr/local/sbin/pxe-dhcp-hook.sh
- name: "Create TFTP directory"
file:
path: "{{ tftp_dir }}"
state: directory
mode: '0755'
owner: nobody
group: nogroup
- name: "Create Win11 directory structure"
file:
path: "{{ web_root }}/win11/{{ item }}"
state: directory
mode: '0755'
loop:
- "EFI/Boot"
- "EFI/Microsoft/Boot"
- "Boot"
- "sources"
- name: "Create Altiris iPXE directory"
file:
path: "{{ web_root }}/Altiris/iPXE"
state: directory
mode: '0755'
- name: "Create boot tool directories"
file:
path: "{{ web_root }}/{{ item }}"
state: directory
mode: '0755'
loop:
- clonezilla
- blancco
- memtest
- name: "Create GetPxeScript.aspx (iPXE boot menu)"
copy:
dest: "{{ web_root }}/Altiris/iPXE/GetPxeScript.aspx"
backup: yes
content: |
#!ipxe
set server 10.9.100.1
:menu
menu GE Aerospace PXE Boot Menu
item --gap -- ---- Windows Deployment ----
item winpe Windows PE (Image Deployment)
item --gap -- ---- Utilities (Secure Boot must be DISABLED) ----
item blancco Blancco Drive Eraser
item clonezilla Clonezilla Live (Disk Imaging)
item memtest Memtest86+ (Memory Diagnostics)
item --gap -- ----
item reboot Reboot
item exit Exit to BIOS
choose --default winpe --timeout 30000 target && goto ${target}
:winpe
echo
echo Windows deployment requires Secure Boot to be ENABLED.
echo If you disabled it for Blancco/Clonezilla, re-enable it now.
echo
prompt --timeout 5000 Press any key to continue (auto-boot in 5s)... && goto winpe_boot || goto winpe_boot
:winpe_boot
kernel http://${server}/win11/wimboot gui
initrd http://${server}/win11/EFI/Microsoft/Boot/boot.stl EFI/Microsoft/Boot/Boot.stl
initrd http://${server}/win11/EFI/Microsoft/Boot/BCD EFI/Microsoft/Boot/BCD
initrd http://${server}/win11/EFI/Boot/bootx64.efi EFI/Boot/bootx64.efi
initrd http://${server}/win11/Boot/boot.sdi Boot/boot.sdi
initrd http://${server}/win11/sources/boot.wim sources/boot.wim
boot
:clonezilla
set base http://${server}/clonezilla
kernel ${base}/vmlinuz boot=live username=user union=overlay config components noswap edd=on nomodeset nodmraid locales= keyboard-layouts= ocs_live_run="ocs-live-general" ocs_live_extra_param="" ocs_live_batch=no net.ifnames=0 nosplash noprompt fetch=${base}/filesystem.squashfs || goto secureboot_warn
initrd ${base}/initrd.img
boot
:blancco
chain http://${server}/blancco/grubx64.efi || goto secureboot_warn
:memtest
kernel http://${server}/memtest/memtest.efi || goto secureboot_warn
boot
:secureboot_warn
echo
echo ======================================================
echo This option requires Secure Boot to be DISABLED.
echo
echo 1. Reboot this machine
echo 2. Press F2 / Del to enter BIOS Setup
echo 3. Disable Secure Boot
echo 4. Save and exit BIOS
echo 5. PXE boot again and select this option
echo
echo Re-enable Secure Boot after completing the task.
echo ======================================================
echo
prompt Press any key to return to menu...
goto menu
:reboot
reboot
:exit
exit
- name: "Ensure Apache listens on port 4433"
lineinfile:
path: /etc/apache2/ports.conf
line: "Listen 4433"
backup: yes
state: present
- name: "Create VirtualHost for Altiris iPXE on 4433"
copy:
dest: /etc/apache2/sites-available/altiris-ipxe.conf
backup: yes
content: |
<VirtualHost *:4433>
DocumentRoot {{ web_root }}
<Directory "{{ web_root }}/Altiris/iPXE">
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
AddType text/plain .aspx
</Directory>
</VirtualHost>
- name: "Enable Altiris iPXE site"
command: a2ensite altiris-ipxe.conf
args:
creates: /etc/apache2/sites-enabled/altiris-ipxe.conf
- name: "Reload Apache to apply changes"
systemd:
name: apache2
state: reloaded
- name: "Create Samba share root"
file:
path: "{{ samba_share }}"
state: directory
mode: '0777'
- name: "Create Clonezilla backup share directory"
file:
path: /srv/samba/clonezilla
state: directory
mode: '0777'
- name: "Create Blancco reports share directory"
file:
path: /srv/samba/blancco-reports
state: directory
mode: '0777'
- name: "Create enrollment share with internal taxonomy"
file:
path: "/srv/samba/enrollment/{{ item }}"
state: directory
mode: '0777'
loop:
- ""
- ppkgs
- scripts
- config
- shopfloor-setup
- pre-install
- pre-install/bios
- pre-install/installers
- installers-post
- installers-post/cmm
- blancco
- logs
- name: "Deploy enrollment share README"
copy:
dest: /srv/samba/enrollment/README.md
mode: '0644'
content: |
# Enrollment Share Layout
Single SMB share mounted by WinPE as Y: during imaging. Subdir layout:
- ppkgs/ GCCH bulk-enrollment PPKGs
- scripts/ run-enrollment.ps1, wait-for-internet.ps1, migrate-to-wifi.ps1
- config/ site-config.json, FlatUnattendW10*.xml, per-site overrides
- shopfloor-setup/ Per-PC-type post-imaging scripts
- pre-install/ WinPE-phase content (bios/, installers/, preinstall.json)
- installers-post/ Post-OOBE app installers (cmm/PCDMIS, etc.)
- blancco/ Blancco custom images / configs
- logs/ Client log uploads
- name: "Deploy PPKG enrollment packages to ppkgs/"
shell: |
set +e
# Copy any whole PPKGs (small enough to fit on FAT32)
cp -f {{ usb_root }}/enrollment/*.ppkg /srv/samba/enrollment/ppkgs/ 2>/dev/null
# Reassemble any split files (foo.ppkg.part.00, .01, ... -> foo.ppkg)
for first in {{ usb_root }}/enrollment/*.part.00; do
[ -e "$first" ] || continue
base="${first%.part.00}"
name="$(basename "$base")"
echo "Reassembling $name from chunks..."
cat "${base}.part."* > "/srv/samba/enrollment/ppkgs/$name"
done
ls -lh /srv/samba/enrollment/ppkgs/*.ppkg 2>/dev/null
ignore_errors: yes
- name: "Deploy enrollment scripts to scripts/"
copy:
src: "{{ item.src }}"
dest: "/srv/samba/enrollment/scripts/{{ item.dest }}"
mode: '0644'
loop:
- { src: "{{ usb_mount }}/shopfloor-setup/run-enrollment.ps1", dest: "run-enrollment.ps1" }
- { src: "{{ usb_mount }}/wait-for-internet.ps1", dest: "wait-for-internet.ps1" }
- { src: "{{ usb_mount }}/migrate-to-wifi.ps1", dest: "migrate-to-wifi.ps1" }
ignore_errors: yes
- name: "Deploy site-config.json to config/"
copy:
src: "{{ usb_mount }}/shopfloor-setup/site-config.json"
dest: /srv/samba/enrollment/config/site-config.json
mode: '0644'
ignore_errors: yes
- name: "Deploy Dell driver packs to shared Out-of-box Drivers"
args:
executable: /bin/bash
shell: |
set +e
SRC="{{ usb_root }}/drivers"
DEST="/srv/samba/winpeapps/_shared/Out-of-box Drivers/Dell_11"
if [ ! -d "$SRC" ]; then
echo "No drivers/ on USB - skipping"
exit 0
fi
mkdir -p "$DEST"
# Copy everything except split chunks
rsync -a --exclude='*.part.*' "$SRC/" "$DEST/"
# Reassemble any split driver files
while IFS= read -r first; do
base="${first%.part.00}"
rel="${base#$SRC/}"
out="$DEST/$rel"
mkdir -p "$(dirname "$out")"
echo "Reassembling $rel from chunks..."
cat "${base}.part."* > "$out"
done < <(find "$SRC" -name '*.part.00')
echo "Deployed Dell drivers from USB"
ignore_errors: yes
- name: "Deploy shopfloor setup scripts to enrollment share"
copy:
src: "{{ usb_mount }}/shopfloor-setup/"
dest: /srv/samba/enrollment/shopfloor-setup/
mode: '0755'
directory_mode: '0755'
ignore_errors: yes
- name: "Deploy preinstall.json to pre-install/"
copy:
src: "{{ usb_mount }}/preinstall/preinstall.json"
dest: /srv/samba/enrollment/pre-install/preinstall.json
mode: '0644'
ignore_errors: yes
# Oracle Client 11.2 is the first app the preinstall runner executes, so it
# has to be present on-disk before the first client images. The 686 MB
# Oracle_OracleDatabase_11r2_V03.zip doesn't belong in git; it rides on the
# USB under oracle/ alongside the BIOS exes. Install-Oracle11r2.cmd is
# tracked at playbook/preinstall/oracle/ and arrives via usb_mount.
- name: "Create pre-install/installers/oracle/ staging dir"
file:
path: /srv/samba/enrollment/pre-install/installers/oracle
state: directory
mode: '0755'
ignore_errors: yes
- name: "Deploy Install-Oracle11r2.cmd to pre-install/installers/oracle/"
copy:
src: "{{ usb_mount }}/preinstall/oracle/Install-Oracle11r2.cmd"
dest: /srv/samba/enrollment/pre-install/installers/oracle/Install-Oracle11r2.cmd
mode: '0755'
ignore_errors: yes
- name: "Deploy Oracle 11.2 zip (686 MB) from USB to pre-install/installers/oracle/"
shell: >
if [ -f "{{ usb_root }}/oracle/Oracle_OracleDatabase_11r2_V03.zip" ]; then
cp -f "{{ usb_root }}/oracle/Oracle_OracleDatabase_11r2_V03.zip" \
/srv/samba/enrollment/pre-install/installers/oracle/
echo "Deployed Oracle 11.2 zip ($(stat -c %s /srv/samba/enrollment/pre-install/installers/oracle/Oracle_OracleDatabase_11r2_V03.zip) bytes)"
else
echo "WARNING: {{ usb_root }}/oracle/Oracle_OracleDatabase_11r2_V03.zip not on USB - preinstall runner will fail on Oracle step until this file is staged at /srv/samba/enrollment/pre-install/installers/oracle/"
fi
ignore_errors: yes
- name: "Deploy BIOS check script and manifest to pre-install/bios/"
copy:
src: "{{ usb_mount }}/shopfloor-setup/BIOS/{{ item }}"
dest: "/srv/samba/enrollment/pre-install/bios/{{ item }}"
mode: '0644'
loop:
- check-bios.cmd
- models.txt
ignore_errors: yes
- name: "Deploy BIOS update binaries from USB to pre-install/bios/"
shell: >
if [ -d "{{ usb_root }}/bios" ]; then
cp -f {{ usb_root }}/bios/*.exe /srv/samba/enrollment/pre-install/bios/ 2>/dev/null || true
count=$(find /srv/samba/enrollment/pre-install/bios -name '*.exe' | wc -l)
echo "Deployed $count BIOS binaries"
else
echo "No bios/ on USB - skipping"
fi
ignore_errors: yes
- name: "Create image upload staging directory"
file:
path: /home/pxe/image-upload
state: directory
mode: '0777'
owner: pxe
group: pxe
- name: "Enable Samba symlink following (shared image dirs)"
blockinfile:
path: /etc/samba/smb.conf
backup: yes
marker: "# {mark} MANAGED - GLOBAL SYMLINKS"
insertafter: '\[global\]'
block: |
follow symlinks = yes
wide links = yes
unix extensions = no
- name: "Samba SMB session handling for WinPE re-image robustness"
blockinfile:
path: /etc/samba/smb.conf
backup: yes
marker: "# {mark} MANAGED - PXE REIMAGE FIX"
insertafter: "# END MANAGED - GLOBAL SYMLINKS"
block: |
# Reduce the chance a WinPE client rebooting mid-imaging leaves a
# stale session on the server that blocks its next connection
# attempt with "System error 53 network path not found". Combined
# with /etc/sysctl.d/99-pxe-conntrack.conf (shorter nf_conntrack
# TCP timeouts) this keeps the conntrack + smbd state in sync with
# the short-lived flows that PXE imaging produces.
socket options = TCP_NODELAY SO_KEEPALIVE IPTOS_LOWDELAY
keepalive = 30
deadtime = 5
- name: "Configure Samba shares"
blockinfile:
path: /etc/samba/smb.conf
backup: yes
block: |
[winpeapps]
path = {{ samba_share }}
browseable = yes
read only = no
guest ok = no
valid users = pxe-upload
force user = root
[clonezilla]
path = /srv/samba/clonezilla
browseable = yes
read only = no
guest ok = no
valid users = pxe-upload
force user = root
comment = Clonezilla backup images
[blancco-reports]
path = /srv/samba/blancco-reports
browseable = yes
read only = no
guest ok = no
valid users = pxe-upload blancco
force user = root
comment = Blancco Drive Eraser reports
[enrollment]
path = /srv/samba/enrollment
browseable = yes
read only = no
guest ok = no
valid users = pxe-upload
force user = root
comment = GCCH bulk enrollment packages
[image-upload]
path = /home/pxe/image-upload
browseable = yes
read only = no
guest ok = no
valid users = pxe-upload
force user = pxe
force group = pxe
comment = PXE image upload staging area
oplocks = no
level2 oplocks = no
strict sync = yes
- name: "Create Samba users (pxe-upload and blancco)"
shell: |
id pxe-upload >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin pxe-upload
echo -e 'pxe\npxe' | smbpasswd -a pxe-upload -s
id blancco >/dev/null 2>&1 || useradd -M -s /usr/sbin/nologin blancco
echo -e 'blancco\nblancco' | smbpasswd -a blancco -s
args:
executable: /bin/bash
changed_when: false
- name: "Deploy nf_conntrack TCP timeout sysctl for PXE workload"
copy:
src: "{{ usb_mount }}/pxe-server-helpers/99-pxe-conntrack.conf"
dest: /etc/sysctl.d/99-pxe-conntrack.conf
mode: '0644'
notify: reload sysctl
- name: "Deploy SMB diagnostic + soft-reset helper scripts"
copy:
src: "{{ usb_mount }}/pxe-server-helpers/{{ item }}"
dest: "/usr/local/sbin/{{ item }}"
mode: '0755'
loop:
- smb-diag.sh
- smb-soft-reset.sh
- name: "Create image-type top-level directories"
file:
path: "{{ samba_share }}/{{ item }}"
state: directory
mode: '0777'
loop: "{{ image_types }}"
- name: "Create Deploy subdirectories for each image type"
file:
path: "{{ samba_share }}/{{ item.0 }}/Deploy/{{ item.1 }}"
state: directory
mode: '0777'
with_nested:
- "{{ image_types }}"
- "{{ deploy_subdirs }}"
- name: "Create Media.tag for FlatSetupLoader.exe drive detection"
copy:
content: ""
dest: "{{ samba_share }}/{{ item }}/Deploy/Control/Media.tag"
mode: '0644'
force: no
loop: "{{ image_types }}"
- name: "Deploy shopfloor unattend.xml template"
copy:
src: "{{ usb_mount }}/FlatUnattendW10-shopfloor.xml"
dest: "{{ samba_share }}/{{ item }}/Deploy/FlatUnattendW10.xml"
mode: '0644'
force: no
loop: "{{ shopfloor_types }}"
ignore_errors: yes
# gea-standard (Win11) + gea-engineer (Win10) + ge-standard/engineer all use
# the same unattend. force: yes because drift between this file and the
# shared copies is what caused the Win10/Win11 search-cleanup regression
# earlier this session (d49f516) — prefer repo as source of truth.
- name: "Deploy standard/engineer unattend.xml"
copy:
src: "{{ usb_mount }}/FlatUnattendW10.xml"
dest: "{{ samba_share }}/{{ item }}/Deploy/FlatUnattendW10.xml"
mode: '0644'
force: yes
loop: "{{ standard_types }}"
ignore_errors: yes
- name: "Daily cron to create/refresh Media.tag for all images"
copy:
content: |
# Create Media.tag in any image with Deploy/Control/ and refresh existing ones
@reboot root for d in {{ samba_share }}/*/Deploy/Control; do [ -d "$d" ] && touch "$d/Media.tag"; done
0 0 * * * root for d in {{ samba_share }}/*/Deploy/Control; do [ -d "$d" ] && touch "$d/Media.tag"; done
dest: /etc/cron.d/media-tag-refresh
mode: '0644'
- name: "Copy WinPE & boot files from USB (skipped if not present)"
copy:
src: "{{ usb_root }}/{{ item.src }}"
dest: "{{ web_root }}/win11/{{ item.dest }}"
mode: '0644'
loop:
- { src: "wimboot", dest: "wimboot" }
- { src: "boot.stl", dest: "EFI/Microsoft/Boot/boot.stl" }
- { src: "BCD", dest: "EFI/Microsoft/Boot/BCD" }
- { src: "bootx64.efi", dest: "EFI/Boot/bootx64.efi" }
- { src: "boot.sdi", dest: "Boot/boot.sdi" }
- { src: "boot.wim", dest: "sources/boot.wim" }
ignore_errors: yes
- name: "Inject startnet.cmd into boot.wim (virtual BOOT/MEDIA volumes)"
shell: |
WIM="{{ web_root }}/win11/sources/boot.wim"
STARTNET="{{ usb_mount }}/startnet.cmd"
if [ -f "$WIM" ] && [ -f "$STARTNET" ]; then
echo "add $STARTNET /Windows/System32/startnet.cmd" | wimupdate "$WIM" 1
echo "Updated startnet.cmd in boot.wim"
else
echo "Skipped: boot.wim or startnet.cmd not found"
fi
args:
executable: /bin/bash
ignore_errors: yes
- name: "Copy iPXE binaries from USB (skipped if not present)"
copy:
src: "{{ usb_root }}/{{ item }}"
dest: "{{ tftp_dir }}/{{ item }}"
mode: '0755'
loop:
- ipxe.efi
ignore_errors: yes
- name: "Copy boot tool files from USB (Clonezilla, Blancco, Memtest)"
shell: >
cp -r "{{ usb_root }}/boot-tools/{{ item }}/"* "{{ web_root }}/{{ item }}/" 2>/dev/null || true
loop:
- clonezilla
- blancco
- memtest
# --- Blancco PXE boot via Ubuntu kernel + switch_root ---
# Blancco's own kernel freezes on Dell Precision towers. Workaround:
# Boot Ubuntu kernel, download Blancco rootfs, overlay mount, switch_root.
- name: "Build Blancco PXE initramfs"
args:
executable: /bin/bash
creates: "{{ web_root }}/blancco/kexec-initrd.img"
shell: |
set -e
WORK=$(mktemp -d)
mkdir -p "$WORK"/{bin,lib/modules,lib64,sbin,usr/share/udhcpc}
# Busybox (static) - bundled on USB at playbook/busybox-static
if [ -f /bin/busybox ]; then
cp /bin/busybox "$WORK/bin/"
elif [ -f "{{ usb_root }}/playbook/busybox-static" ]; then
cp "{{ usb_root }}/playbook/busybox-static" "$WORK/bin/busybox"
chmod +x "$WORK/bin/busybox"
else
echo "ERROR: No busybox available (not at /bin/busybox or on USB)"
exit 1
fi
for cmd in sh awk cat chmod echo grep gunzip ifconfig ip ln losetup ls mkdir mknod mount reboot route sed sleep switch_root tar udhcpc umount wget cpio; do
ln -sf busybox "$WORK/bin/$cmd"
done
# NIC drivers (common server NICs)
KVER=$(uname -r)
KMOD="/lib/modules/$KVER/kernel/drivers/net/ethernet"
for drv in intel/e1000e/e1000e.ko.zst intel/igb/igb.ko.zst broadcom/tg3.ko.zst broadcom/bnx2.ko.zst broadcom/bnxt/bnxt_en.ko.zst broadcom/b44.ko.zst; do
if [ -f "$KMOD/$drv" ]; then
zstd -d "$KMOD/$drv" -o "$WORK/lib/modules/$(basename ${drv%.zst})" 2>/dev/null
fi
done
# Overlay module
OVMOD="/lib/modules/$KVER/kernel/fs/overlayfs/overlay.ko.zst"
if [ -f "$OVMOD" ]; then
zstd -d "$OVMOD" -o "$WORK/lib/modules/overlay.ko" 2>/dev/null
fi
# Init script
cp "{{ usb_root }}/playbook/blancco-init.sh" "$WORK/init"
chmod +x "$WORK/init"
# Build CPIO
cd "$WORK"
find . | cpio -o -H newc 2>/dev/null | gzip > "{{ web_root }}/blancco/kexec-initrd.img"
rm -rf "$WORK"
echo "Built kexec-initrd.img: $(stat -c %s '{{ web_root }}/blancco/kexec-initrd.img') bytes"
ignore_errors: yes
- name: "Copy Ubuntu kernel for Blancco PXE boot"
copy:
src: "/boot/vmlinuz-{{ ansible_kernel }}"
dest: "{{ web_root }}/blancco/vmlinuz-ubuntu"
remote_src: yes
mode: '0644'
- name: "Create TFTP blancco directory"
file:
path: "{{ tftp_dir }}/blancco"
state: directory
owner: nobody
group: nogroup
mode: '0755'
- name: "Create TFTP symlinks for Blancco kernel/initrd (GRUB HTTP times out on large files; TFTP is reliable)"
file:
src: "{{ web_root }}/blancco/{{ item }}"
dest: "{{ tftp_dir }}/blancco/{{ item }}"
state: link
force: yes
owner: nobody
group: nogroup
loop:
- vmlinuz-bde-linux
- initramfs-bde-linux.img
- intel-ucode.img
- amd-ucode.img
- config.img
- name: "Build Ubuntu kernel modules tarball for Blancco"
shell: |
set -e
KVER=$(uname -r)
tar czf "{{ web_root }}/blancco/kmod.tar.gz" -C / "lib/modules/$KVER"
echo "Built kmod.tar.gz: $(du -h '{{ web_root }}/blancco/kmod.tar.gz' | cut -f1)"
args:
creates: "{{ web_root }}/blancco/kmod.tar.gz"
- name: "Deploy Blancco config and preferences (null-stripped)"
shell: |
# Strip null bytes from config.img files and deploy
if [ -f "{{ web_root }}/blancco/config.img" ]; then
WORK=$(mktemp -d)
cd "$WORK"
cpio -id < "{{ web_root }}/blancco/config.img" 2>/dev/null
tr -d '\000' < config.xml > "{{ web_root }}/blancco/config-clean.xml"
rm -rf "$WORK"
fi
# Deploy preferences from playbook (pre-configured with network share)
cp "{{ usb_root }}/playbook/blancco-preferences.xml" "{{ web_root }}/blancco/preferences.xml"
args:
creates: "{{ web_root }}/blancco/config-clean.xml"
- name: "Ensure Samba user for Blancco reports exists (idempotent)"
shell: |
id blancco >/dev/null 2>&1 || useradd -r -s /usr/sbin/nologin blancco
pdbedit -L 2>/dev/null | grep -q '^blancco:' || (echo -e "blancco\nblancco" | smbpasswd -a -s blancco)
changed_when: false
- name: "Check for WinPE deployment content on USB"
stat:
path: "{{ usb_root }}/images"
register: usb_images_dir
- name: "Import WinPE deployment content from USB (if present)"
shell: >
cp -rn "{{ usb_root }}/images/{{ item }}/"* "{{ samba_share }}/{{ item }}/" 2>/dev/null || true
loop: "{{ image_types }}"
when: usb_images_dir.stat.exists
- name: "Restart and enable services"
systemd:
name: "{{ item }}"
state: restarted
enabled: yes
loop:
- dnsmasq
- apache2
- smbd
- name: "Allow necessary firewall ports (UFW)"
ufw:
rule: allow
port: "{{ item }}"
proto: "{{ 'udp' if item in ['67','69'] else 'tcp' }}"
loop:
- "22"
- "67"
- "69"
- "80"
- "4433"
- "445"
- "9009"
- name: "Enable UFW firewall"
ufw:
state: enabled
policy: deny
- name: "Schedule dnsmasq restart 15s after reboot"
copy:
dest: /etc/cron.d/dnsmasq-restart
mode: '0644'
content: |
@reboot root /bin/sleep 15 && /usr/bin/systemctl restart dnsmasq.service
# --- Web Management App (Flask) ---
- name: "Create webapp directory"
file:
path: /opt/pxe-webapp
state: directory
mode: '0755'
- name: "Copy webapp from USB"
shell: >
cp -r "{{ usb_root }}/webapp/"* /opt/pxe-webapp/ 2>/dev/null || true
args:
creates: /opt/pxe-webapp/app.py
- name: "Install webapp Python dependencies (offline wheels)"
args:
executable: /bin/bash
shell: |
# Find the pip-wheels directory on the CIDATA mount
export WHEEL_DIR=""
for d in "{{ usb_root }}/pip-wheels" "{{ usb_mount }}/pip-wheels"; do
if [ -d "$d" ] && compgen -G "$d/*.whl" > /dev/null; then
export WHEEL_DIR="$(cd "$d" && pwd)"
break
fi
done
if [ -n "$WHEEL_DIR" ]; then
# Install wheels directly using Python zipfile (bypasses pip entirely)
# This avoids the pip3/distutils incompatibility between Ubuntu 22.04 debs and Python 3.12
python3 << 'PYEOF'
import zipfile, sysconfig, glob, os
site = sysconfig.get_path('platlib')
wheel_dir = os.environ['WHEEL_DIR']
for whl in sorted(glob.glob(os.path.join(wheel_dir, '*.whl'))):
name = os.path.basename(whl)
print(f' Installing {name}')
with zipfile.ZipFile(whl) as z:
z.extractall(site)
print('All wheels installed to ' + site)
PYEOF
else
# Fallback: try system pip (works if system has internet and compatible pip)
python3 -m pip install --break-system-packages flask lxml 2>/dev/null ||
pip3 install --break-system-packages flask lxml
fi
- name: "Create systemd service for PXE webapp"
copy:
dest: /etc/systemd/system/pxe-webapp.service
content: |
[Unit]
Description=PXE Server Web Management
After=network.target apache2.service
[Service]
Type=simple
User=root
WorkingDirectory=/opt/pxe-webapp
Environment=SAMBA_SHARE={{ samba_share }}
Environment=CLONEZILLA_SHARE=/srv/samba/clonezilla
Environment=WEB_ROOT={{ web_root }}
Environment=BLANCCO_REPORTS=/srv/samba/blancco-reports
Environment=ENROLLMENT_SHARE=/srv/samba/enrollment
Environment=AUDIT_LOG=/var/log/pxe-webapp-audit.log
ExecStart=/usr/bin/python3 app.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
- name: "Enable and start PXE webapp service"
systemd:
name: pxe-webapp
state: started
enabled: yes
daemon_reload: yes
- name: "Configure unified Apache site (static files + webapp proxy)"
copy:
dest: /etc/apache2/sites-available/pxe-server.conf
content: |
Listen 9009
<VirtualHost *:80>
DocumentRoot {{ web_root }}
<Directory "{{ web_root }}">
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
</Directory>
ProxyPreserveHost On
ProxyPass /manage http://127.0.0.1:9010/
ProxyPassReverse /manage http://127.0.0.1:9010/
</VirtualHost>
<VirtualHost *:9009>
Alias /static /opt/pxe-webapp/static
<Directory "/opt/pxe-webapp/static">
Require all granted
Options -Indexes
</Directory>
ProxyPreserveHost On
ProxyPass /static !
ProxyPass / http://127.0.0.1:9010/
ProxyPassReverse / http://127.0.0.1:9010/
</VirtualHost>
- name: "Enable Apache proxy modules"
command: a2enmod proxy proxy_http
args:
creates: /etc/apache2/mods-enabled/proxy.load
- name: "Disable default Apache site"
command: a2dissite 000-default.conf
args:
removes: /etc/apache2/sites-enabled/000-default.conf
- name: "Enable unified PXE server site"
command: a2ensite pxe-server.conf
args:
creates: /etc/apache2/sites-enabled/pxe-server.conf
- name: "Reload Apache after site changes"
systemd:
name: apache2
state: reloaded
- name: "Configure static IP for PXE interface"
copy:
dest: /etc/netplan/50-cloud-init.yaml
backup: yes
content: |
network:
version: 2
renderer: networkd
ethernets:
{{ pxe_iface }}:
dhcp4: no
addresses: [10.9.100.1/24]
notify: "Apply netplan"
handlers:
- name: "Apply netplan"
command: netplan apply
- name: "reload sysctl"
command: sysctl --system