Keep SELinux mount options out of the mount destination
Appending `,z` to a mount destination (`$DATA_DIR:/data,z`) makes docker mount a directory literally named `/data,z`. The app then writes its database into its own container layer instead of the host volume, `container_matches` never matches so every update recreates the container without its data, `backup_database` silently skips, and `resolveHostPath` refuses to provision computers (`/data/computers/1 isn't on a volume`). Build each spec as `source:destination[:options]` instead, with the `z` and `ro` options comma separated in the third field and the destination a plain absolute path. `backup_database` uses the same helper. `rescue_container_data` runs before a container whose /data is not the host's directory is replaced: it stops the app, copies `CONTAINER:/data` out with `docker cp`, moves a non-empty host directory aside and writes the copy in, so repairing an install from the broken form does not lose the database, the media or the computers' home directories. `install.test.ts` lifts the marked `selinux-detection` and `mount-specs` blocks out of install.sh, runs them in bash and parses the specs the way docker does, so the two forms can't drift back.
This commit is contained in:
+94
-17
@@ -345,23 +345,35 @@ if [ "$OS" = "Linux" ] && [ "$(id -u)" -ne 0 ]; then
|
||||
fail "Run the installer as root: curl -fsSL $INSTALL_URL | sudo bash"
|
||||
fi
|
||||
|
||||
SELINUX_SUFFIX=""
|
||||
if [ "$OS" = "Linux" ]; then
|
||||
# SELinux hosts need the `z` mount option so docker relabels the bind
|
||||
# mount. It is a third colon-separated field (`src:/data:z`), never a
|
||||
# suffix on the destination: docker reads `src:/data,z` as the directory
|
||||
# "/data,z", which silently leaves the app writing to its own container
|
||||
# layer, breaks every update (the container is recreated without its data)
|
||||
# and makes computer provisioning fail. Never append to the destination.
|
||||
# MARK:selinux-detection
|
||||
selinux_enforcing() {
|
||||
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce 2>/dev/null || true)" != "Disabled" ]; then
|
||||
SELINUX_SUFFIX=",z"
|
||||
elif [ -e /sys/fs/selinux/enforce ]; then
|
||||
SELINUX_SUFFIX=",z"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
[ -e /sys/fs/selinux/enforce ]
|
||||
}
|
||||
|
||||
ENV_MOUNT="$ENV_FILE:/app/.env:ro"
|
||||
DATA_MOUNT="$DATA_DIR:/data"
|
||||
BACKUP_ARCHIVE_MOUNT="$BACKUP_ARCHIVE_DIR_HOST:/backups-archives"
|
||||
if [ -n "$SELINUX_SUFFIX" ]; then
|
||||
ENV_MOUNT="$ENV_FILE:/app/.env:ro,z"
|
||||
DATA_MOUNT="$DATA_DIR:/data,z"
|
||||
BACKUP_ARCHIVE_MOUNT="$BACKUP_ARCHIVE_DIR_HOST:/backups-archives,z"
|
||||
MOUNT_OPTS=""
|
||||
if [ "$OS" = "Linux" ] && selinux_enforcing; then
|
||||
MOUNT_OPTS="z"
|
||||
fi
|
||||
# END:selinux-detection
|
||||
|
||||
# MARK:mount-specs
|
||||
# A spec is `source:destination[:options]`. Options are comma separated and
|
||||
# only count in that third field, so each spec below either opens the field
|
||||
# with a colon or adds to one that is already there. A destination is
|
||||
# always a plain absolute path.
|
||||
ENV_MOUNT="$ENV_FILE:/app/.env:ro${MOUNT_OPTS:+,$MOUNT_OPTS}"
|
||||
DATA_MOUNT="$DATA_DIR:/data${MOUNT_OPTS:+:$MOUNT_OPTS}"
|
||||
BACKUP_ARCHIVE_MOUNT="$BACKUP_ARCHIVE_DIR_HOST:/backups-archives${MOUNT_OPTS:+:$MOUNT_OPTS}"
|
||||
# END:mount-specs
|
||||
|
||||
# ---------------------------------------------------------------- helpers
|
||||
|
||||
@@ -527,10 +539,7 @@ backup_database() {
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
say "Backing up the database"
|
||||
|
||||
local backup_mount="$data_source:/data"
|
||||
if [ -n "$SELINUX_SUFFIX" ]; then
|
||||
backup_mount="$backup_mount,z"
|
||||
fi
|
||||
local backup_mount="$data_source:/data${MOUNT_OPTS:+:$MOUNT_OPTS}"
|
||||
|
||||
local image_id=""
|
||||
if container_running; then
|
||||
@@ -575,6 +584,71 @@ backup_database() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Whether the container's /data is really the host's data directory.
|
||||
# Older installers wrote the SELinux option onto the destination
|
||||
# (`$DATA_DIR:/data,z`), so docker mounted a directory named "/data,z"
|
||||
# and the app kept its database in the container's own writable layer.
|
||||
# Such a container must have that data lifted out before it is replaced.
|
||||
data_mount_broken() {
|
||||
container_exists || return 1
|
||||
local mounted
|
||||
mounted="$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' "$CONTAINER" 2>/dev/null || true)"
|
||||
[ "$mounted" != "$DATA_DIR" ]
|
||||
}
|
||||
|
||||
# Copies a container's unmounted /data back onto the host before the
|
||||
# container is recreated, so a broken mount is repaired without losing the
|
||||
# database, the media or the computers' home directories. Runs only when
|
||||
# the container's /data is not the host's directory, and refuses to
|
||||
# overwrite a host directory that already holds a database.
|
||||
rescue_container_data() {
|
||||
data_mount_broken || return 0
|
||||
|
||||
# Stop the app first: the copy must see a database nothing is writing
|
||||
# to, and the container is about to be replaced anyway.
|
||||
if container_running; then
|
||||
say "Stopping Cobble to copy its data out"
|
||||
docker stop -t 30 "$CONTAINER" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
local staged
|
||||
staged="$(mktemp -d)"
|
||||
|
||||
if ! docker cp "$CONTAINER:/data/." "$staged/" >/dev/null 2>&1; then
|
||||
rm -rf "$staged"
|
||||
fail "Could not read the container's data. Its database is still in $CONTAINER; copy it out before updating."
|
||||
fi
|
||||
|
||||
# A container that never wrote anything has nothing to rescue: the app
|
||||
# is about to start fresh on the host directory.
|
||||
if [ -z "$(ls -A "$staged" 2>/dev/null)" ]; then
|
||||
rm -rf "$staged"
|
||||
return 0
|
||||
fi
|
||||
|
||||
say "This install's data was inside its container (a broken mount); moving it to $DATA_DIR"
|
||||
|
||||
if [ -n "$(ls -A "$DATA_DIR" 2>/dev/null)" ]; then
|
||||
local aside
|
||||
aside="$DATA_DIR.before-rescue-$(date +%s)"
|
||||
warn "$DATA_DIR was not empty; the copy is kept at $aside"
|
||||
mv "$DATA_DIR" "$aside" || fail "Could not move $DATA_DIR aside"
|
||||
fi
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
cp -a "$staged/." "$DATA_DIR/" || {
|
||||
rm -rf "$staged"
|
||||
fail "Could not copy the rescued data into $DATA_DIR"
|
||||
}
|
||||
rm -rf "$staged"
|
||||
|
||||
if [ -f "$DATA_DIR/db/cobble" ]; then
|
||||
ok "Rescued the database and data into $DATA_DIR"
|
||||
else
|
||||
warn "Rescued data has no database file; the install will start empty"
|
||||
fi
|
||||
}
|
||||
|
||||
safe_remove_dir() {
|
||||
local dir="$1"
|
||||
case "$dir" in
|
||||
@@ -789,6 +863,9 @@ if [ "$FORCE" = 0 ] && container_matches; then
|
||||
fi
|
||||
|
||||
if container_exists; then
|
||||
# A container whose /data was never mounted keeps the database in its
|
||||
# own layer, so lift it out before anything replaces the container.
|
||||
rescue_container_data
|
||||
backup_database
|
||||
say "Replacing the Cobble container"
|
||||
docker stop -t 30 "$CONTAINER" >/dev/null 2>&1 || true
|
||||
|
||||
Reference in New Issue
Block a user