Fix PXE interface detection, add br-pxe bridge to test VM, network upload import

- Playbook: detect interface already configured with 10.9.100.1 before
  falling back to non-default-gateway heuristic (fixes dnsmasq binding
  to wrong NIC when multiple interfaces exist)
- test-vm.sh: auto-attach br-pxe bridge NIC if available on host
- Webapp: add network upload import via SMB share with shared driver
  deduplication and symlinks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-02-11 15:15:14 -05:00
parent 7486b9ed66
commit f4c158a5ac
4 changed files with 290 additions and 37 deletions

View File

@@ -61,10 +61,26 @@
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: >-
{{ (ansible_interfaces
{{ preconfigured_iface | default('',true)
or (ansible_interfaces
| select('match','^e(th|n)')
| reject('equalto','lo')
| reject('equalto', ansible_default_ipv4.interface | default(''))

View File

@@ -138,9 +138,15 @@ echo " Extracted vmlinuz and initrd from casper/"
echo ""
echo "[4/4] Launching VM ($VM_NAME)..."
# Use the default libvirt network (NAT, 192.168.122.0/24)
# The VM gets a DHCP address initially for install access.
# The Ansible playbook will later configure 10.9.100.1/24 for production use.
# Use the default libvirt network (NAT, 192.168.122.0/24) for install access.
# If br-pxe bridge exists, add a second NIC for the isolated PXE switch.
# The Ansible playbook will configure 10.9.100.1/24 on the PXE interface.
PXE_BRIDGE_ARGS=""
if ip link show br-pxe &>/dev/null; then
PXE_BRIDGE_ARGS="--network bridge=br-pxe,model=virtio"
echo " Found br-pxe bridge, adding isolated switch NIC"
fi
virt-install \
--name "$VM_NAME" \
--memory "$VM_RAM" \
@@ -149,6 +155,7 @@ virt-install \
--disk path="$UBUNTU_ISO",device=cdrom,readonly=on \
--disk path="$CIDATA_ISO",device=cdrom \
--network network=default \
$PXE_BRIDGE_ARGS \
--os-variant ubuntu24.04 \
--graphics none \
--console pty,target_type=serial \

View File

@@ -51,6 +51,20 @@ def audit(action, detail=""):
SAMBA_SHARE = os.environ.get("SAMBA_SHARE", "/srv/samba/winpeapps")
CLONEZILLA_SHARE = os.environ.get("CLONEZILLA_SHARE", "/srv/samba/clonezilla")
BLANCCO_REPORTS = os.environ.get("BLANCCO_REPORTS", "/srv/samba/blancco-reports")
UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "/home/pxe/image-upload")
SHARED_DIR = os.path.join(SAMBA_SHARE, "_shared")
# Subdirs inside Deploy/ shared across ALL image types
SHARED_DEPLOY_GLOBAL = ["Out-of-box Drivers"]
# Subdirs inside Deploy/ shared within the same image family (by prefix)
SHARED_DEPLOY_SCOPED = {
"gea-": ["Operating Systems"],
"ge-": ["Operating Systems"],
}
# Sibling dirs at image root shared within the same image family
SHARED_ROOT_DIRS = {
"gea-": ["Sources"],
"ge-": ["Sources"],
}
WEB_ROOT = os.environ.get("WEB_ROOT", "/var/www/html")
BOOT_WIM = os.path.join(WEB_ROOT, "win11", "sources", "boot.wim")
@@ -118,6 +132,11 @@ def qwcm(attr):
# Utility helpers
# ---------------------------------------------------------------------------
def image_root(image_type):
"""Return the root directory for an image type."""
return os.path.join(SAMBA_SHARE, image_type)
def deploy_path(image_type):
"""Return the Deploy directory for an image type."""
return os.path.join(SAMBA_SHARE, image_type, "Deploy")
@@ -175,6 +194,103 @@ def find_usb_mounts():
return sorted(set(mounts))
def find_upload_sources():
"""Return sub-directories inside UPLOAD_DIR that look like image content."""
sources = []
if os.path.isdir(UPLOAD_DIR):
# Include the upload dir itself if it has content
try:
entries = os.listdir(UPLOAD_DIR)
if entries:
sources.append(UPLOAD_DIR)
# Also include immediate subdirectories
for entry in entries:
full = os.path.join(UPLOAD_DIR, entry)
if os.path.isdir(full):
sources.append(full)
except OSError:
pass
return sources
def _import_deploy(src_deploy, dst_deploy, target=""):
"""Copy Deploy directory contents, merging shared subdirs into _shared."""
# Build list of scoped shared dirs for this target
scoped_shared = []
prefix_key = ""
for prefix, dirs in SHARED_DEPLOY_SCOPED.items():
if target.startswith(prefix):
scoped_shared = dirs
prefix_key = prefix
break
os.makedirs(dst_deploy, exist_ok=True)
for item in os.listdir(src_deploy):
src_item = os.path.join(src_deploy, item)
dst_item = os.path.join(dst_deploy, item)
if not os.path.isdir(src_item):
shutil.copy2(src_item, dst_item)
continue
# Global shared (e.g., Out-of-box Drivers) — one copy for all
if item in SHARED_DEPLOY_GLOBAL:
shared_dest = os.path.join(SHARED_DIR, item)
os.makedirs(shared_dest, exist_ok=True)
_merge_tree(src_item, shared_dest)
_replace_with_symlink(dst_item, shared_dest)
continue
# Scoped shared (e.g., Operating Systems) — per family prefix
if item in scoped_shared:
shared_dest = os.path.join(SHARED_DIR, f"{prefix_key}{item}")
os.makedirs(shared_dest, exist_ok=True)
_merge_tree(src_item, shared_dest)
_replace_with_symlink(dst_item, shared_dest)
continue
# Normal copy
if os.path.exists(dst_item):
shutil.rmtree(dst_item)
shutil.copytree(src_item, dst_item)
def _replace_with_symlink(link_path, target_path):
"""Replace a file/dir/symlink at link_path with a symlink to target_path."""
if os.path.islink(link_path):
os.remove(link_path)
elif os.path.isdir(link_path):
shutil.rmtree(link_path)
os.symlink(target_path, link_path)
def _merge_tree(src, dst):
"""Recursively merge src tree into dst, overwriting existing files."""
for item in os.listdir(src):
s = os.path.join(src, item)
d = os.path.join(dst, item)
if os.path.isdir(s):
if os.path.isdir(d):
_merge_tree(s, d)
else:
if os.path.exists(d):
os.remove(d)
shutil.copytree(s, d)
else:
os.makedirs(os.path.dirname(d), exist_ok=True)
shutil.copy2(s, d)
def allowed_import_source(source):
"""Check if a source path is a valid import location (USB or upload dir)."""
usb = find_usb_mounts()
if any(source == m or source.startswith(m + "/") for m in usb):
return True
if source == UPLOAD_DIR or source.startswith(UPLOAD_DIR + "/"):
return os.path.isdir(source)
return False
# ---------------------------------------------------------------------------
# XML helpers — parse / build unattend.xml
# ---------------------------------------------------------------------------
@@ -514,6 +630,7 @@ def dashboard():
@app.route("/images/import", methods=["GET", "POST"])
def images_import():
usb_mounts = find_usb_mounts()
upload_sources = find_upload_sources()
images = [image_status(it) for it in IMAGE_TYPES]
if request.method == "POST":
@@ -528,32 +645,67 @@ def images_import():
flash("Invalid target image type.", "danger")
return redirect(url_for("images_import"))
# Validate source is under an allowed USB mount path
allowed = find_usb_mounts()
if not any(source == m or source.startswith(m + "/") for m in allowed):
flash("Source path is not on a mounted USB device.", "danger")
if not allowed_import_source(source):
flash("Source path is not a valid import location.", "danger")
return redirect(url_for("images_import"))
if not os.path.isdir(source):
flash(f"Source path does not exist: {source}", "danger")
return redirect(url_for("images_import"))
root = image_root(target)
dest = deploy_path(target)
try:
os.makedirs(dest, exist_ok=True)
# Use rsync-style copy: copy contents of source into dest
for item in os.listdir(source):
src_items = os.listdir(source)
# Detect layout: if source has Deploy/, Sources/, Tools/ at top
# level, it's the full image root structure (USB-style).
# Otherwise treat it as Deploy/ contents directly.
top_dirs = {d for d in src_items if os.path.isdir(os.path.join(source, d))}
full_layout = "Deploy" in top_dirs
if full_layout:
# Determine which root-level dirs are shared for this target
shared_root = []
for prefix, dirs in SHARED_ROOT_DIRS.items():
if target.startswith(prefix):
shared_root = dirs
break
# Full image root: copy Deploy contents + sibling dirs
for item in src_items:
src_item = os.path.join(source, item)
dst_item = os.path.join(dest, item)
if os.path.isdir(src_item):
if item == "Deploy":
_import_deploy(src_item, dest, target)
elif os.path.isdir(src_item) and item in shared_root:
# Shared sibling: merge into _shared/{prefix}{item}
# and symlink from image root
prefix_key = target.split("-")[0] + "-"
shared_dest = os.path.join(SHARED_DIR, f"{prefix_key}{item}")
os.makedirs(shared_dest, exist_ok=True)
_merge_tree(src_item, shared_dest)
dst_item = os.path.join(root, item)
if os.path.islink(dst_item):
os.remove(dst_item)
elif os.path.isdir(dst_item):
shutil.rmtree(dst_item)
os.symlink(shared_dest, dst_item)
elif os.path.isdir(src_item):
# Non-shared sibling dirs (Tools) go into image root
dst_item = os.path.join(root, item)
if os.path.exists(dst_item):
shutil.rmtree(dst_item)
shutil.copytree(src_item, dst_item)
else:
shutil.copy2(src_item, dst_item)
shutil.copy2(src_item, os.path.join(root, item))
else:
# Flat layout: treat source as Deploy contents
_import_deploy(source, dest, target)
audit("IMAGE_IMPORT", f"{source} -> {target}")
flash(
f"Successfully imported content from {source} to {FRIENDLY_NAMES.get(target, target)}.",
f"Successfully imported content to {FRIENDLY_NAMES.get(target, target)}.",
"success",
)
except Exception as exc:
@@ -564,6 +716,7 @@ def images_import():
return render_template(
"import.html",
usb_mounts=usb_mounts,
upload_sources=upload_sources,
images=images,
image_types=IMAGE_TYPES,
friendly_names=FRIENDLY_NAMES,

View File

@@ -6,6 +6,81 @@
<div class="row">
<div class="col-lg-8">
<!-- Network Upload Import -->
<div class="card mb-3">
<div class="card-header">
<i class="bi bi-cloud-upload me-2"></i> Import from Network Upload
</div>
<div class="card-body">
{% if upload_sources %}
<form method="POST" id="uploadImportForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="uploadSource" class="form-label fw-semibold">Source</label>
<select class="form-select" name="source" id="uploadSource" required>
<option value="">-- Select upload source --</option>
{% for src in upload_sources %}
<option value="{{ src }}">{{ src }}</option>
{% endfor %}
</select>
<div class="form-text">
Files uploaded via SMB to <code>\\10.9.100.1\image-upload</code>
</div>
</div>
<div class="mb-3">
<label for="uploadTarget" class="form-label fw-semibold">Target Image Type</label>
<select class="form-select" name="target" id="uploadTarget" required>
<option value="">-- Select target image --</option>
{% for it in image_types %}
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
{% endfor %}
</select>
<div class="form-text">
Content will be copied into the Deploy directory. Shared resources
(Out-of-box Drivers) are stored once and linked across all image types.
</div>
</div>
<div class="alert alert-info d-flex align-items-start" role="alert">
<i class="bi bi-info-circle-fill me-2 mt-1"></i>
<div>
<strong>Shared Drivers:</strong> Out-of-box Drivers are automatically pooled
into a shared directory and symlinked for each image type to save disk space.
</div>
</div>
<div class="alert alert-warning d-flex align-items-start" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2 mt-1"></i>
<div>
<strong>Warning:</strong> Existing files in the target Deploy directory with the
same names will be overwritten. This operation may take several minutes for large
images.
</div>
</div>
<button type="submit" class="btn btn-primary" id="uploadImportBtn">
<i class="bi bi-download me-1"></i> Start Import
</button>
</form>
{% else %}
<div class="text-center py-4">
<i class="bi bi-cloud-slash display-4 text-muted"></i>
<h5 class="mt-3 text-muted">No Upload Content Found</h5>
<p class="text-muted mb-0">
Map <code>\\10.9.100.1\image-upload</code> on your Windows PC and copy
the Deploy directory contents there.
</p>
<button class="btn btn-outline-secondary btn-sm mt-3" onclick="location.reload()">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
{% endif %}
</div>
</div>
<!-- USB Import -->
<div class="card">
<div class="card-header">
<i class="bi bi-usb-drive me-2"></i> Import from USB Drive
@@ -54,7 +129,7 @@
</button>
</form>
{% else %}
<div class="text-center py-5">
<div class="text-center py-4">
<i class="bi bi-usb-plug display-4 text-muted"></i>
<h5 class="mt-3 text-muted">No USB Drives Detected</h5>
<p class="text-muted mb-0">
@@ -107,14 +182,16 @@
{% block extra_scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
var form = document.getElementById('importForm');
['importForm', 'uploadImportForm'].forEach(function(formId) {
var form = document.getElementById(formId);
if (form) {
form.addEventListener('submit', function() {
var btn = document.getElementById('importBtn');
var btn = form.querySelector('button[type="submit"]');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Importing...';
});
}
});
});
</script>
{% endblock %}