docker/update-mods.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
# Updates mods already present in the mods volume directory against the
# Factorio mod portal. Invoked from entrypoint.sh only when UPDATE_MODS=true.
# Does not install new mods, resolve dependencies, or manage mod-list.json —
# it only version-bumps zips that are already there.
set -euo pipefail
FACTORIO_DIR="${FACTORIO_DIR:-/factorio}"
FACTORIO_HOME="${FACTORIO_HOME:-/opt/factorio}"
MODS_DIR="${FACTORIO_DIR}/mods"
BIN="${FACTORIO_HOME}/factorio"
log() { printf '[update-mods] %s\n' "$*"; }
warn() { printf '[update-mods] WARNING: %s\n' "$*" >&2; }
die() { printf '[update-mods] ERROR: %s\n' "$*" >&2; exit 1; }
# Returns success if $2 is a newer version than $1. Both are dotted numeric
# versions like "1.2.3" — compared numerically per-component via `sort -V`
# rather than lexically, since lexical comparison would rank "1.10.0" below
# "1.9.0".
mod_version_newer() {
local current="$1" candidate="$2"
[ "$current" != "$candidate" ] && \
[ "$(printf '%s\n%s\n' "$current" "$candidate" | sort -V | tail -n1)" = "$candidate" ]
}
# Splits "name_1.2.3.zip" into "name version". Echoes nothing if the
# filename doesn't match that pattern. Always returns 0 regardless of
# match — callers assign its output via command substitution, and under
# `set -e` a non-zero exit from a bare `var=$(...)` assignment would abort
# the whole script, which is exactly the single-bad-mod-shouldn't-abort-
# boot behavior this script must avoid.
parse_mod_filename() {
local base="$1"
base="${base%.zip}"
if [[ "$base" =~ ^(.+)_([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
printf '%s %s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}"
fi
return 0
}
# Returns success if $1 is present in the comma-separated list $2.
is_ignored() {
local name="$1" list="$2" item
[ -z "$list" ] && return 1
local items=()
IFS=',' read -ra items <<< "$list"
for item in "${items[@]:-}"; do
item="${item#"${item%%[![:space:]]*}"}"
item="${item%"${item##*[![:space:]]}"}"
[ "$item" = "$name" ] && return 0
done
return 1
}
# Checks one mod against the portal and replaces it in place if a newer
# release exists for the given Factorio major.minor line. Never aborts the
# script — any failure here is logged and the mod is left as-is.
update_one_mod() {
local zip_path="$1" name="$2" current_version="$3" factorio_line="$4"
local username="$5" token="$6"
local name_enc
name_enc="$(jq -rn --arg n "${name}" '$n|@uri' 2>/dev/null)" || true
if [ -z "${name_enc}" ]; then
warn "${name}: could not URL-encode mod name, leaving ${current_version} in place"
return 0
fi
local api_url="https://mods.factorio.com/api/mods/${name_enc}"
local releases
if ! releases="$(curl -fsSL --connect-timeout 10 --max-time 30 --retry 2 "${api_url}" 2>/dev/null | jq -c '.releases // []' 2>/dev/null)"; then
warn "${name}: could not reach mod portal, leaving ${current_version} in place"
return 0
fi
local best
best="$(printf '%s' "${releases}" | jq -c --arg line "${factorio_line}" '
[.[] | select(.info_json.factorio_version == $line)]
| sort_by(.version | split(".") | map(tonumber))
| last // empty
' 2>/dev/null)" || true
if [ -z "${best}" ] || [ "${best}" = "null" ]; then
log "${name}: no release for Factorio ${factorio_line}, leaving ${current_version} in place"
return 0
fi
local best_version best_url best_sha1
if ! { best_version="$(printf '%s' "${best}" | jq -r '.version' 2>/dev/null)" && \
best_url="$(printf '%s' "${best}" | jq -r '.download_url' 2>/dev/null)" && \
best_sha1="$(printf '%s' "${best}" | jq -r '.sha1' 2>/dev/null)"; }; then
warn "${name}: could not parse release metadata, leaving ${current_version} in place"
return 0
fi
if ! mod_version_newer "${current_version}" "${best_version}"; then
log "${name}: ${current_version} is already current"
return 0
fi
if ! [[ "${best_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
warn "${name}: portal returned an invalid version '${best_version}', leaving ${current_version} in place"
return 0
fi
if [[ "${best_url}" != /* ]]; then
warn "${name}: portal returned an invalid download URL, leaving ${current_version} in place"
return 0
fi
log "${name}: ${current_version} -> ${best_version}"
local username_enc token_enc
username_enc="$(jq -rn --arg u "${username}" '$u|@uri' 2>/dev/null)" || true
token_enc="$(jq -rn --arg t "${token}" '$t|@uri' 2>/dev/null)" || true
if [ -z "${username_enc}" ] || [ -z "${token_enc}" ]; then
warn "${name}: could not URL-encode credentials, leaving ${current_version} in place"
return 0
fi
local tmp_file="${zip_path}.new"
if ! curl -fsSL --connect-timeout 10 --max-time 300 --retry 2 \
"https://mods.factorio.com${best_url}?username=${username_enc}&token=${token_enc}" \
-o "${tmp_file}" 2>/dev/null; then
warn "${name}: download failed, leaving ${current_version} in place"
rm -f "${tmp_file}"
return 0
fi
local actual_sha1
if ! actual_sha1="$(sha1sum "${tmp_file}" | cut -d' ' -f1)"; then
warn "${name}: could not compute checksum, leaving ${current_version} in place"
rm -f "${tmp_file}"
return 0
fi
if [ "${actual_sha1}" != "${best_sha1}" ]; then
warn "${name}: checksum mismatch after download, leaving ${current_version} in place"
rm -f "${tmp_file}"
return 0
fi
local new_zip_path="${MODS_DIR}/${name}_${best_version}.zip"
if ! mv "${tmp_file}" "${new_zip_path}"; then
warn "${name}: could not move new version into place, leaving ${current_version} in place"
rm -f "${tmp_file}"
return 0
fi
if ! rm -f "${zip_path}"; then
warn "${name}: could not remove old version ${current_version}; both ${current_version} and ${best_version} are now present on disk"
fi
}
main() {
local username="${FACTORIO_USERNAME:-${USERNAME:-}}"
local token="${FACTORIO_TOKEN:-${TOKEN:-}}"
if [ -z "${username}" ] || [ -z "${token}" ]; then
die "UPDATE_MODS=true but FACTORIO_USERNAME/FACTORIO_TOKEN are not set"
fi
local version_output factorio_line
version_output="$("${BIN}" --version)"
if [[ "${version_output}" =~ Version:\ ([0-9]+)\.([0-9]+)\. ]]; then
factorio_line="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
else
die "could not determine Factorio version from: ${version_output}"
fi
log "checking mods against Factorio ${factorio_line}"
shopt -s nullglob
# Clean up any leftover temp files from a previous boot that was killed
# mid-download; nullglob is already set above so a non-matching glob
# expands to nothing rather than the literal pattern.
rm -f "${MODS_DIR}"/*.zip.new
local zip_path
for zip_path in "${MODS_DIR}"/*.zip; do
local base parsed name current_version
base="$(basename "${zip_path}")"
parsed="$(parse_mod_filename "${base}")"
if [ -z "${parsed}" ]; then
warn "${base}: filename doesn't match name_x.y.z.zip, skipping"
continue
fi
name="${parsed% *}"
current_version="${parsed#* }"
if is_ignored "${name}" "${MODS_IGNORE:-}"; then
log "${name}: in MODS_IGNORE, skipping"
continue
fi
update_one_mod "${zip_path}" "${name}" "${current_version}" "${factorio_line}" "${username}" "${token}"
done
shopt -u nullglob
}
# Allow sourcing this file (e.g. from scripts/test-update-mods.sh) without
# running main() — only run it when executed directly.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi