Files
zima-apps/scripts/map-unraid-images-to-zima-apps.sh
T
Joachim Friberg 4b43e80f06 Updated metadata
Changed author and developer to Joachim Friberg
2026-03-20 13:15:56 +01:00

332 lines
9.3 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
UNRAID_IMAGES=""
INVENTORY_PATH=""
OUT_PATH="artifacts/unraid-to-zima-map.yaml"
usage() {
cat <<USAGE
Usage: $0 --unraid-images <file> --inventory <yaml> [--out <yaml>]
Options:
--unraid-images <file> Text file with one image reference per line (required)
--inventory <yaml> Inventory YAML from discover-zima-repos-and-apps.sh (required)
--out <yaml> Output YAML (default: artifacts/unraid-to-zima-map.yaml)
-h, --help Show this help
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--unraid-images)
UNRAID_IMAGES="${2:-}"
shift 2
;;
--inventory)
INVENTORY_PATH="${2:-}"
shift 2
;;
--out)
OUT_PATH="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "ERROR: Unknown argument: $1" >&2
usage
exit 2
;;
esac
done
if [[ -z "$UNRAID_IMAGES" || -z "$INVENTORY_PATH" ]]; then
echo "ERROR: --unraid-images and --inventory are required" >&2
usage
exit 2
fi
if [[ ! -f "$UNRAID_IMAGES" ]]; then
echo "ERROR: unraid image file not found: $UNRAID_IMAGES" >&2
exit 2
fi
if [[ ! -f "$INVENTORY_PATH" ]]; then
echo "ERROR: inventory file not found: $INVENTORY_PATH" >&2
exit 2
fi
mkdir -p "$(dirname "$OUT_PATH")"
UNRAID_IMAGES="$UNRAID_IMAGES" \
INVENTORY_PATH="$INVENTORY_PATH" \
OUT_PATH="$OUT_PATH" \
python3 - <<'PY'
import datetime as dt
import os
import re
import sys
import yaml
unraid_images_path = os.environ["UNRAID_IMAGES"]
inventory_path = os.environ["INVENTORY_PATH"]
out_path = os.environ["OUT_PATH"]
ALIASES = {
"arch-sonarr": "sonarr",
"arch-radarr": "radarr",
"arch-prowlarr": "prowlarr",
"arch-overseerr": "overseerr",
"arch-flaresolverr": "flaresolverr",
"arch-qbittorrentvpn": "qbittorrent",
"arch-plexpass": "plex",
"open-webui": "openwebui",
"open_webui": "openwebui",
"act_runner": "act-runner",
"socket-proxy": "socketproxy",
"postgres": "postgresql",
"postgresql": "postgres",
}
GENERIC_BASE_IMAGES = {
"redis",
"postgres",
"postgresql",
"mariadb",
"mysql",
"mongo",
"mongodb",
"nginx",
"memcached",
"rabbitmq",
"valkey",
}
def normalize_text(value: str) -> str:
value = (value or "").strip().lower()
return re.sub(r"[^a-z0-9]+", "", value)
def normalize_image_ref(ref: str) -> str:
value = (ref or "").strip().strip('"\'')
if not value:
return ""
if "@" in value:
value = value.split("@", 1)[0]
if "/" in value:
prefix, tail = value.rsplit("/", 1)
if ":" in tail:
tail = tail.split(":", 1)[0]
value = f"{prefix}/{tail}"
else:
if ":" in value:
value = value.split(":", 1)[0]
return value.lower()
def basename_image(normalized_ref: str) -> str:
if not normalized_ref:
return ""
return normalized_ref.rsplit("/", 1)[-1]
def candidate_names_from_unraid_image(image_ref: str):
normalized = normalize_image_ref(image_ref)
base = basename_image(normalized)
candidates = set()
if base:
candidates.add(base)
candidates.add(base.replace("_", "-"))
if base.startswith("arch-"):
candidates.add(base[5:])
if base in ALIASES:
candidates.add(ALIASES[base])
replaced = base.replace("_", "-")
if replaced in ALIASES:
candidates.add(ALIASES[replaced])
return {c for c in candidates if c}
with open(inventory_path, "r", encoding="utf-8") as handle:
inventory = yaml.safe_load(handle) or {}
apps = inventory.get("apps") or []
if not isinstance(apps, list):
print("ERROR: inventory apps must be a list", file=sys.stderr)
sys.exit(1)
app_records = []
for app in apps:
if not isinstance(app, dict):
continue
app_id = str(app.get("app_id") or "").strip()
if not app_id:
continue
title = str(app.get("title") or "").strip()
repo_url = str(app.get("repo_url") or "").strip()
images = app.get("images") if isinstance(app.get("images"), list) else []
normalized_images = []
seen_images = set()
for image in images:
if not isinstance(image, str):
continue
norm = normalize_image_ref(image)
if not norm or norm in seen_images:
continue
seen_images.add(norm)
normalized_images.append(norm)
app_records.append(
{
"app_id": app_id,
"title": title,
"repo_url": repo_url,
"images": normalized_images,
"id_key": normalize_text(app_id),
"title_key": normalize_text(title),
}
)
index_exact = {}
index_basename = {}
for app in app_records:
for image in app["images"]:
index_exact.setdefault(image, []).append(app)
bname = basename_image(image)
if bname:
index_basename.setdefault(bname, []).append(app)
def app_name_matches(app, candidate_base: str) -> bool:
key = normalize_text(candidate_base)
if not key:
return False
candidate_keys = {key}
alias = ALIASES.get(candidate_base)
if alias:
candidate_keys.add(normalize_text(alias))
for alias_source, alias_target in ALIASES.items():
if alias_target == candidate_base:
candidate_keys.add(normalize_text(alias_source))
return (app["id_key"] in candidate_keys) or (app["title_key"] and app["title_key"] in candidate_keys)
with open(unraid_images_path, "r", encoding="utf-8") as handle:
unraid_images = [line.strip() for line in handle if line.strip() and not line.strip().startswith("#")]
mapping = []
for raw_image in unraid_images:
normalized = normalize_image_ref(raw_image)
base = basename_image(normalized)
matched = {}
# Strong match: exact normalized image reference from app compose.
# For generic bases (redis/postgres/etc), only accept exact matches when app name matches.
for app in index_exact.get(normalized, []):
if base in GENERIC_BASE_IMAGES and not app_name_matches(app, base):
continue
key = (app["repo_url"], app["app_id"])
matched.setdefault(
key,
{
"app_id": app["app_id"],
"title": app["title"],
"repo_url": app["repo_url"],
"reasons": set(),
},
)["reasons"].add("image_exact")
# Medium match: basename image.
# Restrict generic/shared images to avoid false positives from sidecar dependencies.
if base:
for app in index_basename.get(base, []):
if base in GENERIC_BASE_IMAGES and not app_name_matches(app, base):
continue
if base not in GENERIC_BASE_IMAGES and not app_name_matches(app, base):
continue
key = (app["repo_url"], app["app_id"])
matched.setdefault(
key,
{
"app_id": app["app_id"],
"title": app["title"],
"repo_url": app["repo_url"],
"reasons": set(),
},
)["reasons"].add("image_basename")
# Fallback match: inferred app name from image aliases.
for candidate_name in candidate_names_from_unraid_image(raw_image):
candidate_key = normalize_text(candidate_name)
if not candidate_key:
continue
for app in app_records:
if candidate_key == app["id_key"] or (app["title_key"] and candidate_key == app["title_key"]):
key = (app["repo_url"], app["app_id"])
matched.setdefault(
key,
{
"app_id": app["app_id"],
"title": app["title"],
"repo_url": app["repo_url"],
"reasons": set(),
},
)["reasons"].add("name_alias")
matched_apps = []
for key in sorted(matched.keys(), key=lambda item: (item[0].lower(), item[1].lower())):
info = matched[key]
matched_apps.append(
{
"app_id": info["app_id"],
"title": info["title"],
"repo_url": info["repo_url"],
"reasons": sorted(info["reasons"]),
}
)
if not matched_apps:
status = "missing"
elif len(matched_apps) == 1:
status = "found"
else:
status = "ambiguous"
mapping.append(
{
"unraid_image": raw_image,
"normalized_image": normalized,
"match_status": status,
"matched_apps": matched_apps,
}
)
summary = {
"total": len(mapping),
"found": sum(1 for item in mapping if item["match_status"] == "found"),
"missing": sum(1 for item in mapping if item["match_status"] == "missing"),
"ambiguous": sum(1 for item in mapping if item["match_status"] == "ambiguous"),
}
output = {
"schema_version": 1,
"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
"inventory_source": inventory_path,
"summary": summary,
"mapping": mapping,
}
with open(out_path, "w", encoding="utf-8") as handle:
yaml.safe_dump(output, handle, sort_keys=False, allow_unicode=False)
print(f"Wrote {out_path}")
print(f"Summary: total={summary['total']} found={summary['found']} missing={summary['missing']} ambiguous={summary['ambiguous']}")
PY