diff --git a/playbook/pxe_server_setup.yml b/playbook/pxe_server_setup.yml index 8cee93c..bd0b190 100644 --- a/playbook/pxe_server_setup.yml +++ b/playbook/pxe_server_setup.yml @@ -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: diff --git a/test-vm.sh b/test-vm.sh index 183aa13..04a94a1 100755 --- a/test-vm.sh +++ b/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 \ diff --git a/webapp/app.py b/webapp/app.py index 8745707..11e3143 100644 --- a/webapp/app.py +++ b/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, diff --git a/webapp/templates/import.html b/webapp/templates/import.html index e3cbc4a..6c2a570 100644 --- a/webapp/templates/import.html +++ b/webapp/templates/import.html @@ -6,6 +6,81 @@
+ Map \\10.9.100.1\image-upload on your Windows PC and copy
+ the Deploy directory contents there.
+
@@ -107,14 +182,16 @@ {% block extra_scripts %} {% endblock %}