Files
esh-pfi-infrastructure/services/lora-training-worker/worker/invocation.py
T
vh b617a8b674 feat(lora-worker): add optional train_id to POST /train (explicit publish-path namespace)
comfy-dev's explicit-over-implicit call: arbo now sends train_id, so the
worker no longer derives the loras/trained/{train_id}/ namespace from
output_dir.parent (which coupled it to arbo's handoff layout). train_id is
optional + path-safe-validated; when present it wins, else the path
derivation remains as the fallback. Wired through TrainRequest ->
validate_request -> published_relative_path -> _publish_lora. 18 tests green.
2026-07-07 01:49:24 -07:00

191 lines
9.0 KiB
Python

"""Fixed-invocation command builder — the enforcement point for INV-T7.
arbo hands the worker a set of BOUNDED parameters; this module turns them into the ONE
`accelerate launch sdxl_train_network.py …` command the worker is allowed to run. Every
parameter is validated (type, range, allowlist, path-containment) before a single argv
element is produced. There is NO path from an arbo request to a free-form argument — the
flag set is a constant, only the *values* of vetted parameters vary.
`build_command` is a pure function (no I/O, no subprocess) so it is trivially unit-testable:
feed it a request dict, assert the argv + env. `InvalidTrainRequest` maps to HTTP 422.
"""
from __future__ import annotations
import re
from pathlib import Path
from . import config
# Safe token for names that land in a filename / kohya folder path. No shell metachars,
# no path separators, no leading dot — even though we never use a shell (argv list), this
# also protects the on-disk paths built from `output_name` / `trigger` / `subject_class`.
_SAFE_TOKEN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 _.\-]{0,63}$")
_SAFE_OUTPUT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$") # filename stem, no spaces
class InvalidTrainRequest(ValueError):
"""A train request failed validation → HTTP 422 (never a silent downgrade)."""
def _require(cond: bool, msg: str) -> None:
if not cond:
raise InvalidTrainRequest(msg)
def _validate_under(path_str: str, roots: tuple[Path, ...], field: str) -> Path:
"""Resolve `path_str` and require it to sit under one of `roots` (no traversal escape)."""
_require(isinstance(path_str, str) and path_str.startswith("/"), f"{field} must be an absolute path")
p = Path(path_str)
# Reject traversal explicitly even before resolve() (belt): no '..' components.
_require(".." not in p.parts, f"{field} must not contain '..'")
resolved = p.resolve()
for root in roots:
try:
resolved.relative_to(root.resolve())
return resolved
except ValueError:
continue
allowed = ", ".join(str(r) for r in roots)
raise InvalidTrainRequest(f"{field} {resolved} is not under an allowed root ({allowed})")
def validate_request(req: dict) -> dict:
"""Validate + normalize an arbo /train request. Returns a clean param dict or raises."""
# ---- tier -> (steps, dim, res) --------------------------------------------------
tier_raw = req.get("tier")
_require(tier_raw in config.TIERS, f"tier must be one of {sorted(config.TIERS)}; got {tier_raw!r}")
tier = str(tier_raw)
spec = config.TIERS[tier]
# ---- device_index (int, 0=3090 / 1=A6000 under PCI_BUS_ID) ----------------------
device_index = req.get("device_index")
_require(isinstance(device_index, int) and device_index in (config.DEVICE_3090, config.DEVICE_A6000),
f"device_index must be {config.DEVICE_3090} (3090) or {config.DEVICE_A6000} (A6000)")
# ---- tier/device fit: quality (1024) is A6000-only (§4.6) -----------------------
if tier == "quality":
_require(device_index in config.QUALITY_ONLY_DEVICES,
"tier 'quality' (1024) requires the A6000 (device_index=1); the 3090 cannot fit it")
# ---- names (safe tokens; used in argv AND on-disk paths) ------------------------
trigger = req.get("trigger")
subject_class = req.get("subject_class")
output_name = req.get("output_name")
_require(isinstance(trigger, str) and bool(_SAFE_TOKEN.match(trigger)), "trigger is not a safe token")
_require(isinstance(subject_class, str) and bool(_SAFE_TOKEN.match(subject_class)),
"subject_class is not a safe token")
_require(isinstance(output_name, str) and bool(_SAFE_OUTPUT_NAME.match(output_name)),
"output_name is not a safe filename stem")
# ---- repeats + seed (bounded ints) ----------------------------------------------
repeats = req.get("repeats")
_require(isinstance(repeats, int) and 1 <= repeats <= 100, "repeats must be an int in [1, 100]")
seed = req.get("seed", 42)
_require(isinstance(seed, int) and 0 <= seed <= 2**31 - 1, "seed must be a non-negative int32")
# ---- train_id (optional; arbo's id — the publish-path namespace, Phase 2) -------
# Explicit over the derive-from-output_dir.parent fallback (decouples the worker from
# arbo's handoff layout). Path-safe token since it lands in the loras/trained/{train_id}/ path.
train_id = req.get("train_id")
if train_id is not None:
_require(isinstance(train_id, str) and bool(_SAFE_OUTPUT_NAME.match(train_id)),
"train_id is not a safe token")
# ---- paths (containment-checked) ------------------------------------------------
dataset_dir = _validate_under(req.get("dataset_dir", ""), (config.HANDOFF_ROOT,), "dataset_dir")
output_dir = _validate_under(req.get("output_dir", ""), (config.HANDOFF_ROOT,), "output_dir")
base_model_path = _validate_under(req.get("base_model_path", ""), config.ALLOWED_MODEL_ROOTS, "base_model_path")
return {
"tier": tier,
"steps": spec["steps"],
"dim": spec["dim"],
"resolution": spec["resolution"],
"device_index": device_index,
"trigger": trigger,
"subject_class": subject_class,
"output_name": output_name,
"repeats": repeats,
"seed": seed,
"dataset_dir": str(dataset_dir),
"output_dir": str(output_dir),
"base_model_path": str(base_model_path),
"train_id": train_id,
}
def published_relative_path(output_dir: str, output_name: str, train_id: str | None = None) -> str:
"""The ComfyUI-relative loras path for a succeeded LoRA (Phase 2 publish step).
Uses the explicit `train_id` (arbo now sends it — decoupled from the path layout); falls
back to deriving it from the handoff layout `HANDOFF_ROOT/{train_id}/output` (the name of the
dir the LoRA was written into) when absent. Returns e.g. `trained/392cf898ac03/name.safetensors`,
the exact string a ComfyUI `LoraLoader.lora_name` widget takes. Pure — no filesystem touch."""
tid = train_id or Path(output_dir).parent.name
return f"{config.PUBLISH_SUBDIR}/{tid}/{output_name}.safetensors"
def build_command(req: dict) -> tuple[list[str], dict[str, str], dict]:
"""Validate `req` and return `(argv, env_overlay, params)` for the fixed sd-scripts invocation.
argv is a LIST (never a shell string) — no metachar interpretation is possible. env_overlay
is merged onto os.environ by the caller (the CUDA ordering + allocator knobs from §4.6).
`params` is the vetted, normalized parameter dict (so the caller need not re-validate).
"""
p = validate_request(req)
h = config.SDXL_HPARAMS
batch = h["train_batch_size_full"] if p["device_index"] == config.DEVICE_A6000 else h["train_batch_size_lean"]
alpha = int(round(p["dim"] * float(h["network_alpha_ratio"])))
res = f"{p['resolution']},{p['resolution']}"
argv = [
str(config.ACCELERATE_BIN), "launch",
"--num_processes", "1",
"--num_cpu_threads_per_process", "1",
"--mixed_precision", h["mixed_precision"],
"--dynamo_backend", "no",
str(config.SDXL_TRAIN_SCRIPT),
"--pretrained_model_name_or_path", p["base_model_path"],
"--train_data_dir", p["dataset_dir"],
"--output_dir", p["output_dir"],
"--output_name", p["output_name"],
"--save_model_as", "safetensors",
"--save_precision", h["save_precision"],
"--mixed_precision", h["mixed_precision"],
# LoRA network (§4.6 tier dim; unet-only per the lean recipe)
"--network_module", "networks.lora",
"--network_dim", str(p["dim"]),
"--network_alpha", str(alpha),
"--network_train_unet_only",
# steps + resolution (§4.6 tier table)
"--max_train_steps", str(p["steps"]),
"--resolution", res,
"--enable_bucket",
"--train_batch_size", batch,
# memory-lean base (§4.6 — the proven Sindra flag set)
"--gradient_checkpointing",
"--cache_latents", "--cache_latents_to_disk",
"--cache_text_encoder_outputs", "--cache_text_encoder_outputs_to_disk",
"--optimizer_type", "adamw8bit",
"--no_half_vae",
"--sdpa",
"--caption_extension", ".txt", # CRITICAL: sd-scripts defaults to .caption → would skip our captions
# optimizer schedule (agent-discretion defaults; confirm vs Sindra)
"--learning_rate", h["learning_rate"],
"--lr_scheduler", h["lr_scheduler"],
"--lr_warmup_steps", h["lr_warmup_steps"],
"--min_snr_gamma", h["min_snr_gamma"],
"--noise_offset", h["noise_offset"],
"--max_data_loader_n_workers", h["max_data_loader_n_workers"],
"--persistent_data_loader_workers",
"--seed", str(p["seed"]),
]
env_overlay = {
"CUDA_DEVICE_ORDER": "PCI_BUS_ID", # index 0=3090, 1=A6000 (recipe-pinned ordering)
"CUDA_VISIBLE_DEVICES": str(p["device_index"]),
"PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True",
}
return argv, env_overlay, p