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:
@@ -61,19 +61,35 @@
|
||||
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
|
||||
| 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')
|
||||
)) }}
|
||||
{{ 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:
|
||||
|
||||
13
test-vm.sh
13
test-vm.sh
@@ -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 \
|
||||
|
||||
183
webapp/app.py
183
webapp/app.py
@@ -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_item = os.path.join(source, item)
|
||||
dst_item = os.path.join(dest, item)
|
||||
if os.path.isdir(src_item):
|
||||
if os.path.exists(dst_item):
|
||||
shutil.rmtree(dst_item)
|
||||
shutil.copytree(src_item, dst_item)
|
||||
else:
|
||||
shutil.copy2(src_item, dst_item)
|
||||
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)
|
||||
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, 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,
|
||||
|
||||
@@ -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');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function() {
|
||||
var btn = document.getElementById('importBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Importing...';
|
||||
});
|
||||
}
|
||||
['importForm', 'uploadImportForm'].forEach(function(formId) {
|
||||
var form = document.getElementById(formId);
|
||||
if (form) {
|
||||
form.addEventListener('submit', function() {
|
||||
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 %}
|
||||
|
||||
Reference in New Issue
Block a user