fix(docker): migrate volumes before non-root startup

This commit is contained in:
sligter
2026-07-10 16:15:31 +08:00
parent 431b50d615
commit d79de891b8
5 changed files with 231 additions and 4 deletions
+5 -3
View File
@@ -169,12 +169,14 @@ WORKDIR /app
COPY run.py ./
COPY src/ ./src/
COPY template_examples/ ./template_examples/
COPY docker-healthcheck.sh docker-entrypoint.sh /usr/local/bin/
COPY docker-healthcheck.sh docker-entrypoint.sh docker-permissions-init.sh /usr/local/bin/
COPY .env.example ./.env
# Create directories and set permissions in one layer
RUN sed -i 's/\r$//' /usr/local/bin/docker-healthcheck.sh /usr/local/bin/docker-entrypoint.sh && \
chmod +x /usr/local/bin/docker-healthcheck.sh /usr/local/bin/docker-entrypoint.sh && \
RUN sed -i 's/\r$//' /usr/local/bin/docker-healthcheck.sh /usr/local/bin/docker-entrypoint.sh /usr/local/bin/docker-permissions-init.sh && \
chmod +x /usr/local/bin/docker-healthcheck.sh \
/usr/local/bin/docker-entrypoint.sh \
/usr/local/bin/docker-permissions-init.sh && \
mkdir -p temp/ai_responses_cache temp/style_genes_cache temp/summeryanyfile_cache temp/templates_cache \
research_reports lib/Linux lib/MacOS lib/Windows uploads data && \
chown -R landppt:landppt /app /home/landppt && \
+34
View File
@@ -7,6 +7,7 @@ x-landppt-build: &landppt-build
x-landppt-volumes: &landppt-volumes
- ./:/app
- ${LANDPPT_ENV_FILE:-./.env}:/app/.env
- landppt_data:/app/data
- landppt_uploads:/app/uploads
- landppt_reports:/app/research_reports
@@ -51,6 +52,8 @@ x-landppt-env: &landppt-env
S3_PRESIGNED_URL_EXPIRES_SECONDS: ${S3_PRESIGNED_URL_EXPIRES_SECONDS:-3600}
x-landppt-depends-on: &landppt-depends-on
permissions-init:
condition: service_completed_successfully
postgres:
condition: service_healthy
valkey:
@@ -61,6 +64,37 @@ x-landppt-depends-on: &landppt-depends-on
condition: service_completed_successfully
services:
permissions-init:
build: *landppt-build
image: ${LANDPPT_DEV_IMAGE:-landppt-dev:latest}
user: "0:0"
entrypoint: ["/usr/local/bin/docker-permissions-init.sh"]
environment:
LANDPPT_UID: "10001"
LANDPPT_GID: "10001"
volumes:
- ${LANDPPT_ENV_FILE:-./.env}:/mnt/landppt/env/.env
- landppt_data:/mnt/landppt/data
- landppt_uploads:/mnt/landppt/uploads
- landppt_reports:/mnt/landppt/reports
- landppt_cache:/mnt/landppt/cache
- landppt_lib:/mnt/landppt/lib
network_mode: "none"
read_only: true
restart: "no"
cap_drop:
- ALL
cap_add:
- CHOWN
- FOWNER
- DAC_OVERRIDE
- SETUID
- SETGID
security_opt:
- no-new-privileges:true
healthcheck:
disable: true
landppt:
build: *landppt-build
image: ${LANDPPT_DEV_IMAGE:-landppt-dev:latest}
+33 -1
View File
@@ -1,5 +1,5 @@
x-landppt-volumes: &landppt-volumes
- ./.env:/app/.env
- ${LANDPPT_ENV_FILE:-./.env}:/app/.env
- landppt_data:/app/data
- landppt_uploads:/app/uploads
- landppt_reports:/app/research_reports
@@ -44,6 +44,8 @@ x-landppt-env: &landppt-env
S3_PRESIGNED_URL_EXPIRES_SECONDS: ${S3_PRESIGNED_URL_EXPIRES_SECONDS:-3600}
x-landppt-depends-on: &landppt-depends-on
permissions-init:
condition: service_completed_successfully
postgres:
condition: service_healthy
valkey:
@@ -54,6 +56,36 @@ x-landppt-depends-on: &landppt-depends-on
condition: service_completed_successfully
services:
permissions-init:
image: ${LANDPPT_IMAGE:-bradleylzh/landppt:latest}
user: "0:0"
entrypoint: ["/usr/local/bin/docker-permissions-init.sh"]
environment:
LANDPPT_UID: "10001"
LANDPPT_GID: "10001"
volumes:
- ${LANDPPT_ENV_FILE:-./.env}:/mnt/landppt/env/.env
- landppt_data:/mnt/landppt/data
- landppt_uploads:/mnt/landppt/uploads
- landppt_reports:/mnt/landppt/reports
- landppt_cache:/mnt/landppt/cache
- landppt_lib:/mnt/landppt/lib
network_mode: "none"
read_only: true
restart: "no"
cap_drop:
- ALL
cap_add:
- CHOWN
- FOWNER
- DAC_OVERRIDE
- SETUID
- SETGID
security_opt:
- no-new-privileges:true
healthcheck:
disable: true
landppt:
image: ${LANDPPT_IMAGE:-bradleylzh/landppt:latest}
shm_size: ${LANDPPT_SHM_SIZE:-4gb}
+101
View File
@@ -0,0 +1,101 @@
#!/bin/sh
set -eu
TARGET_UID="${LANDPPT_UID:-10001}"
TARGET_GID="${LANDPPT_GID:-10001}"
MOUNT_ROOT="/mnt/landppt"
MARKER_NAME=".landppt-permissions-v1"
log() {
printf '[permissions-init] %s\n' "$1"
}
warn() {
printf '[permissions-init] WARNING: %s\n' "$1" >&2
}
fail() {
printf '[permissions-init] ERROR: %s\n' "$1" >&2
exit 1
}
validate_access() {
target_path="$1"
target_kind="$2"
if ! /opt/venv/bin/python - "$TARGET_UID" "$TARGET_GID" "$target_path" "$target_kind" <<'PY'
import os
import sys
uid = int(sys.argv[1])
gid = int(sys.argv[2])
path = sys.argv[3]
kind = sys.argv[4]
os.setgroups([])
os.setgid(gid)
os.setuid(uid)
if kind == "file":
descriptor = os.open(path, os.O_WRONLY | os.O_APPEND)
os.close(descriptor)
else:
probe = os.path.join(path, f".landppt-write-test-{os.getpid()}")
descriptor = os.open(probe, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
os.close(descriptor)
os.unlink(probe)
PY
then
fail "UID ${TARGET_UID} cannot write ${target_path}"
fi
}
migrate_volume() {
volume_path="$1"
marker_path="${volume_path}/${MARKER_NAME}"
[ -d "$volume_path" ] || fail "Volume path is missing: ${volume_path}"
if [ ! -f "$marker_path" ]; then
log "Migrating ${volume_path} to ${TARGET_UID}:${TARGET_GID}"
chown -R "${TARGET_UID}:${TARGET_GID}" "$volume_path"
chmod -R u+rwX "$volume_path"
validate_access "$volume_path" directory
: > "$marker_path"
chown "${TARGET_UID}:${TARGET_GID}" "$marker_path"
chmod 600 "$marker_path"
else
log "Migration marker found for ${volume_path}; validating"
validate_access "$volume_path" directory
fi
}
configure_env_file() {
env_path="${MOUNT_ROOT}/env/.env"
[ -e "$env_path" ] || fail "Mounted .env is missing: ${env_path}"
[ -f "$env_path" ] || fail "Mounted .env is not a regular file: ${env_path}"
if ! chgrp "$TARGET_GID" "$env_path" 2>/dev/null; then
warn "Could not change .env group; checking effective access"
fi
if ! chmod g+rw,o-rwx "$env_path" 2>/dev/null; then
warn "Could not change .env mode; checking effective access"
fi
validate_access "$env_path" file
}
main() {
[ "$(id -u)" -eq 0 ] || fail "Permission migration must run as root"
configure_env_file
for volume_name in data uploads reports cache lib; do
migrate_volume "${MOUNT_ROOT}/${volume_name}"
done
log "Permission migration complete"
}
main "$@"
+58
View File
@@ -42,3 +42,61 @@ def test_entrypoint_checks_identity_and_never_repairs_permissions():
assert "chmod " not in entrypoint
assert "chown " not in entrypoint
assert 'cp "/app/.env"' not in entrypoint
def compose_init_service(compose_text: str) -> str:
return compose_text.split("\n permissions-init:\n", 1)[1].split(
"\n landppt:\n", 1
)[0]
def compose_dependency_anchor(compose_text: str) -> str:
return compose_text.split(
"x-landppt-depends-on: &landppt-depends-on", 1
)[1].split("\n\nservices:", 1)[0]
@pytest.mark.parametrize("compose_path", ["docker-compose.yml", "docker-compose-dev.yaml"])
def test_compose_migrates_permissions_before_app_start(compose_path: str):
compose_text = read_repo_file(compose_path)
init_service = compose_init_service(compose_text)
dependencies = compose_dependency_anchor(compose_text)
assert 'user: "0:0"' in init_service
assert 'entrypoint: ["/usr/local/bin/docker-permissions-init.sh"]' in init_service
assert 'network_mode: "none"' in init_service
assert "read_only: true" in init_service
assert 'restart: "no"' in init_service
assert "- ALL" in init_service
for capability in ("CHOWN", "FOWNER", "DAC_OVERRIDE", "SETUID", "SETGID"):
assert f"- {capability}" in init_service
assert "no-new-privileges:true" in init_service
for mount in (
"${LANDPPT_ENV_FILE:-./.env}:/mnt/landppt/env/.env",
"landppt_data:/mnt/landppt/data",
"landppt_uploads:/mnt/landppt/uploads",
"landppt_reports:/mnt/landppt/reports",
"landppt_cache:/mnt/landppt/cache",
"landppt_lib:/mnt/landppt/lib",
):
assert mount in init_service
assert "/app" not in init_service
assert "${LANDPPT_ENV_FILE:-./.env}:/app/.env" in compose_text
assert "permissions-init:" in dependencies
assert "condition: service_completed_successfully" in dependencies
def test_permission_migration_script_is_idempotent_and_validates_as_target_user():
script = read_repo_file("docker-permissions-init.sh")
dockerfile = read_repo_file("Dockerfile")
assert ".landppt-permissions-v1" in script
assert 'LANDPPT_UID:-10001' in script
assert 'LANDPPT_GID:-10001' in script
assert "os.setgid(gid)" in script
assert "os.setuid(uid)" in script
assert 'chown -R "${TARGET_UID}:${TARGET_GID}"' in script
assert "chmod -R u+rwX" in script
assert "docker-permissions-init.sh /usr/local/bin/" in dockerfile