Files
pxe-server/webapp/services/deploy.py
cproudlock 2ed2cbe2e9 Webapp redesign: fixes from the functional regression test
- Unattend: a command's '...' menu stayed open after Move up/down and
  covered the next row's menu, so the next click could stage removing the
  wrong command. The menu now closes and focus returns to its button.
- '...' menus close on an outside click, on Escape and when another
  opens (static/app.js), for every page.
- Drivers: Remove asks to confirm again, as before the redesign. Long zip
  paths in a row body wrap (the page scrolled sideways at 390 px).
- Rollback review returns to the page it was opened from (Dell page or
  Updates overview), for both Cancel and the rollback itself.
- To-do: the ServiceNow period offers a year again (366 days).
- Imaging floor: a search that matches a log-only bay opens that group;
  its open state survives the live refresh. 'Clear all' states the
  number of session files it deletes, hidden finished ones included.
- Dashboard: the image key pattern is valid under the browser's v flag
  (client-side validation was silently skipped).
- Import: allowed_import_source() compares real paths, so
  '<upload dir>/../..' is refused. This path traversal predates the
  redesign; the import moves the source tree, so it mattered.
2026-10-14 14:48:10 -04:00

117 lines
4.4 KiB
Python

"""Image deploy import logic: copy/move from a USB or upload-dir source
into ``SAMBA_SHARE/<image_type>/Deploy/`` while merging shared subdirs
(``Out-of-box Drivers`` etc.) into ``SAMBA_SHARE/_shared/`` and replacing
the per-image copies with symlinks. This is what lets two image types
re-use the same multi-GB driver tree without doubling disk usage.
"""
import os
import shutil
import subprocess
import config
from services.system import find_usb_mounts
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 sync_tree(src, dst, move=False, checksum=True):
"""Copy src -> dst with rsync, SKIPPING files whose content already
matches and transferring only new or changed files.
src may be a file or a directory (its contents merge into dst). With
checksum=True rsync compares by hash (--checksum), so a re-import only
rewrites files that actually differ instead of replacing everything;
set checksum=False for the faster size+mtime comparison. move=True
removes source files after a successful transfer (frees the upload dir).
Existing dst files not present in src are left untouched (merge, not
mirror - no --delete)."""
flags = ["-a"] # recurse, preserve perms/times/symlinks
if checksum:
flags.append("--checksum") # compare by content hash, not size+mtime
if move:
flags.append("--remove-source-files")
if os.path.isdir(src):
os.makedirs(dst, exist_ok=True)
src_arg = src.rstrip("/") + "/" # trailing slash = merge CONTENTS into dst
else:
parent = os.path.dirname(dst)
if parent:
os.makedirs(parent, exist_ok=True)
src_arg = src
subprocess.run(["rsync", *flags, src_arg, dst], check=True)
if move and os.path.isdir(src):
# --remove-source-files empties files but leaves the dir skeleton; prune it
for root, _dirs, _files in os.walk(src, topdown=False):
try:
os.rmdir(root)
except OSError:
pass
def _merge_tree(src, dst, move=False):
"""Backwards-compatible alias: checksum-aware merge of src into dst."""
sync_tree(src, dst, move=move)
def import_deploy(src_deploy, dst_deploy, target="", move=False):
"""Import Deploy/ contents, redirecting shared subdirs into _shared/."""
scoped_shared = []
prefix_key = ""
for prefix, dirs in config.SHARED_DEPLOY_SCOPED.items():
if target.startswith(prefix):
scoped_shared = dirs
prefix_key = prefix
break
_transfer = shutil.move if move else shutil.copy2
_transfer_tree = shutil.move if move else shutil.copytree
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):
_transfer(src_item, dst_item)
continue
if item in config.SHARED_DEPLOY_GLOBAL:
shared_dest = os.path.join(config.SHARED_DIR, item)
os.makedirs(shared_dest, exist_ok=True)
_merge_tree(src_item, shared_dest, move=move)
_replace_with_symlink(dst_item, shared_dest)
continue
if item in scoped_shared:
shared_dest = os.path.join(config.SHARED_DIR, f"{prefix_key}{item}")
os.makedirs(shared_dest, exist_ok=True)
_merge_tree(src_item, shared_dest, move=move)
_replace_with_symlink(dst_item, shared_dest)
continue
if os.path.isdir(dst_item):
_merge_tree(src_item, dst_item, move=move)
else:
_transfer_tree(src_item, dst_item)
def allowed_import_source(source):
"""True if source is a USB mount or under the upload dir."""
# Compare real paths: "<upload dir>/../.." passes a plain prefix test.
real = os.path.realpath(source)
usb = [os.path.realpath(m) for m in find_usb_mounts()]
if any(real == m or real.startswith(m + "/") for m in usb):
return True
up = os.path.realpath(config.UPLOAD_DIR)
if real == up or real.startswith(up + "/"):
return os.path.isdir(real)
return False