-
-
Notifications
You must be signed in to change notification settings - Fork 32
fix: harden Railway startup persistence and readiness #224
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 7 commits
4e085f3
aff0a37
2e52158
5bc085a
f0c77f5
6ec3b4a
1a32f1d
8539088
f65fee2
e8bd797
07cf507
a6debb3
c830f9e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,10 +6,11 @@ | |
| # create /data/engraphis.db or customer state under /data/.engraphis and crashes at | ||
| # startup with `sqlite3.OperationalError: unable to open database file`. | ||
| # | ||
| # We therefore start the container as root, chown the mounted volume to `engraphis`, and | ||
| # exec the real command as `engraphis` via gosu — keeping the deliberate non-root runtime | ||
| # while making the volume writable. When not running as root (e.g. a local `docker run` | ||
| # that already dropped privileges) this is a no-op passthrough. | ||
| # We therefore start the container as root, repair ownership once, and exec the real command | ||
| # as `engraphis` via gosu — keeping the deliberate non-root runtime while making the volume | ||
| # writable. A marker avoids recursively walking a large Hugging Face cache on every restart. | ||
| # When not running as root (e.g. a local `docker run` that already dropped privileges) this is | ||
| # a no-op passthrough. | ||
| set -e | ||
|
|
||
| # Default bind host, decided at runtime (not baked into the image). Uvicorn's `::` | ||
|
|
@@ -27,11 +28,164 @@ if [ -z "${ENGRAPHIS_HOST:-}" ]; then | |
| fi | ||
|
|
||
| if [ "$(id -u)" = "0" ]; then | ||
| # ENGRAPHIS_STATE_DIR defaults to /data/.engraphis; ensure both it and the volume root | ||
| # exist and are owned by the app user. `|| true` so a transient FS hiccup never blocks | ||
| # startup — the app surfaces any real write failure itself. | ||
| mkdir -p "${ENGRAPHIS_STATE_DIR:-/data/.engraphis}" 2>/dev/null || true | ||
| chown -R engraphis:engraphis /data 2>/dev/null || true | ||
| # Validate every existing component without resolving through a symlink. The trusted | ||
| # config path is operator-configured and may be outside /data, so checking only its | ||
| # leaf or final parent would let an app-writable intermediate directory redirect root's | ||
| # chmod/chown into the image. Reject dot-dot paths rather than guessing their target. | ||
| reject_linked_path() { | ||
| path=$1 | ||
| case "$path" in | ||
| /*) ;; | ||
| *) return 1 ;; | ||
| esac | ||
| remainder=${path#/} | ||
| current= | ||
| while [ -n "$remainder" ]; do | ||
| case "$remainder" in | ||
| */*) | ||
| component=${remainder%%/*} | ||
| remainder=${remainder#*/} | ||
| ;; | ||
| *) | ||
| component=$remainder | ||
| remainder= | ||
| ;; | ||
| esac | ||
| case "$component" in | ||
| ""|.) continue ;; | ||
| ..) return 1 ;; | ||
| esac | ||
| if [ -n "$current" ]; then | ||
| current="$current/$component" | ||
| else | ||
| current="/$component" | ||
| fi | ||
| if [ -L "$current" ]; then | ||
| return 1 | ||
| fi | ||
| done | ||
| return 0 | ||
| } | ||
|
|
||
| # ENGRAPHIS_STATE_DIR defaults to /data/.engraphis. Repair the complete volume only on | ||
| # first boot; later restarts verify the mount and state roots without walking the cache. | ||
| state_dir="${ENGRAPHIS_STATE_DIR:-/data/.engraphis}" | ||
| ownership_marker="${state_dir}/.volume-ownership" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the supported Useful? React with 👍 / 👎. |
||
| config_file="${ENGRAPHIS_ENV_FILE:-}" | ||
| if ! reject_linked_path "$state_dir"; then | ||
| printf '%s\n' "[engraphis] refusing linked or unnormalized state path: $state_dir" >&2 | ||
| exit 1 | ||
| fi | ||
| # The state directory is app-writable after first boot. Reject a planted link or | ||
| # non-directory before mkdir/chown can follow it into a root-owned image path. | ||
| if [ -L "$state_dir" ]; then | ||
| printf '%s\n' "[engraphis] refusing symlinked state directory: $state_dir" >&2 | ||
| exit 1 | ||
| elif [ -e "$state_dir" ] && [ ! -d "$state_dir" ]; then | ||
| printf '%s\n' "[engraphis] refusing non-directory state path: $state_dir" >&2 | ||
| exit 1 | ||
| fi | ||
| if ! mkdir -p "$state_dir"; then | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On a fresh AGENTS.md reference: AGENTS.md:L48-L48 Useful? React with 👍 / 👎. |
||
| printf '%s\n' "[engraphis] unable to create state directory: $state_dir" >&2 | ||
| exit 1 | ||
| fi | ||
| if [ -n "$config_file" ]; then | ||
| if ! reject_linked_path "$config_file"; then | ||
| printf '%s\n' "[engraphis] refusing linked or unnormalized trusted config path: $config_file" >&2 | ||
| exit 1 | ||
| fi | ||
| config_parent=$(dirname "$config_file") | ||
| if [ -L "$config_parent" ]; then | ||
|
Coding-Dev-Tools marked this conversation as resolved.
|
||
| printf '%s\n' "[engraphis] refusing symlinked trusted config directory: $config_parent" >&2 | ||
| exit 1 | ||
| fi | ||
| config_parent_created=0 | ||
| if [ ! -e "$config_parent" ]; then | ||
| config_parent_created=1 | ||
| elif [ ! -d "$config_parent" ]; then | ||
| printf '%s\n' "[engraphis] refusing non-directory trusted config parent: $config_parent" >&2 | ||
| exit 1 | ||
| fi | ||
| if ! mkdir -p "$config_parent"; then | ||
| printf '%s\n' "[engraphis] unable to create config directory: $config_parent" >&2 | ||
| exit 1 | ||
| fi | ||
| if [ "$config_parent_created" = "1" ]; then | ||
| if ! reject_linked_path "$config_parent" || [ ! -d "$config_parent" ]; then | ||
| printf '%s\n' "[engraphis] refusing changed trusted config directory: $config_parent" >&2 | ||
| exit 1 | ||
| fi | ||
| if ! chown engraphis:engraphis "$config_parent"; then | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an allowed absolute config path has multiple missing directories under an app-owned ancestor (for example, AGENTS.md reference: AGENTS.md:L48-L48 Useful? React with 👍 / 👎. |
||
| printf '%s\n' "[engraphis] unable to own trusted config directory" >&2 | ||
| exit 1 | ||
| fi | ||
| fi | ||
| if ! reject_linked_path "$config_file"; then | ||
| printf '%s\n' "[engraphis] refusing symlinked trusted config file: $config_file" >&2 | ||
| exit 1 | ||
| fi | ||
| if [ ! -e "$config_file" ] && ! : > "$config_file"; then | ||
| printf '%s\n' "[engraphis] unable to create trusted config file: $config_file" >&2 | ||
| exit 1 | ||
| fi | ||
| if ! reject_linked_path "$config_file" || [ ! -f "$config_file" ]; then | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a restored or externally provisioned Useful? React with 👍 / 👎. |
||
| printf '%s\n' "[engraphis] refusing changed or non-regular trusted config file: $config_file" >&2 | ||
| exit 1 | ||
| fi | ||
| if ! chmod 600 "$config_file"; then | ||
| printf '%s\n' "[engraphis] unable to restrict trusted config file: $config_file" >&2 | ||
| exit 1 | ||
| fi | ||
| fi | ||
| # The app user owns the persistent marker after first boot. Fail closed if it has | ||
| # replaced that trusted root-startup input with a symlink or a non-regular path: | ||
| # chown follows symlinks by default and would otherwise let the marker redirect | ||
| # root's ownership change to an arbitrary target on the mounted volume. | ||
| if [ -L "$ownership_marker" ] || ! reject_linked_path "$ownership_marker"; then | ||
| printf '%s\n' "[engraphis] refusing symlinked volume ownership marker: $ownership_marker" >&2 | ||
| exit 1 | ||
| fi | ||
| if [ ! -e "$ownership_marker" ]; then | ||
| if ! chown -R engraphis:engraphis /data; then | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| printf '%s\n' "[engraphis] unable to repair /data ownership" >&2 | ||
| exit 1 | ||
| fi | ||
| if ! : > "$ownership_marker"; then | ||
| printf '%s\n' "[engraphis] unable to create volume ownership marker" >&2 | ||
| exit 1 | ||
| fi | ||
| if ! chown engraphis:engraphis "$ownership_marker"; then | ||
| printf '%s\n' "[engraphis] unable to own volume ownership marker" >&2 | ||
| exit 1 | ||
| fi | ||
| elif [ ! -f "$ownership_marker" ]; then | ||
| printf '%s\n' "[engraphis] refusing non-regular volume ownership marker: $ownership_marker" >&2 | ||
| exit 1 | ||
| elif ! chown engraphis:engraphis /data "$state_dir" "$ownership_marker"; then | ||
|
Coding-Dev-Tools marked this conversation as resolved.
Outdated
Coding-Dev-Tools marked this conversation as resolved.
Outdated
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| printf '%s\n' "[engraphis] unable to verify /data ownership" >&2 | ||
| exit 1 | ||
| fi | ||
| if [ -n "$config_file" ]; then | ||
| # A pre-existing config directory may be a separate root-owned mount. Do not | ||
| # chown an arbitrary existing host path; fail closed if it is unusable instead | ||
| # of starting a dashboard whose settings silently cannot persist. | ||
| config_owner=$(stat -c '%u' "$config_parent" 2>/dev/null || true) | ||
| app_owner=$(id -u engraphis) | ||
| if [ -z "$config_owner" ] || [ "$config_owner" != "$app_owner" ]; then | ||
| printf '%s\n' "[engraphis] trusted config directory must be owned by engraphis: $config_parent" >&2 | ||
| exit 1 | ||
| fi | ||
| fi | ||
| if [ -n "$config_file" ]; then | ||
| if ! reject_linked_path "$config_file" || [ ! -f "$config_file" ]; then | ||
| printf '%s\n' "[engraphis] refusing changed trusted config file: $config_file" >&2 | ||
| exit 1 | ||
| fi | ||
| if ! chown engraphis:engraphis "$config_file"; then | ||
| printf '%s\n' "[engraphis] unable to own trusted config file" >&2 | ||
| exit 1 | ||
| fi | ||
| fi | ||
| exec gosu engraphis "$@" | ||
| fi | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| """Exercise the actual POSIX path validator without running privileged startup.""" | ||
| import os | ||
| from pathlib import Path | ||
| import shutil | ||
| import subprocess | ||
|
|
||
| import pytest | ||
|
|
||
|
|
||
| pytestmark = pytest.mark.skipif(os.name == "nt" or not shutil.which("sh"), | ||
| reason="POSIX path and symlink semantics required") | ||
|
|
||
|
|
||
| def _validate(path: str) -> int: | ||
| entrypoint = (Path(__file__).resolve().parents[1] / "docker-entrypoint.sh").read_text() | ||
| body = entrypoint.split(" reject_linked_path() {", 1)[1].split("\n }", 1)[0] | ||
| script = 'reject_linked_path() {' + body + '\n}\nreject_linked_path "$1"\n' | ||
| return subprocess.run(["sh", "-c", script, "validator", path], check=False).returncode | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("path", ["relative/config.env", "../config.env", "/tmp/../etc/config.env"]) | ||
| def test_root_path_validation_rejects_relative_and_parent_traversal(path): | ||
| assert _validate(path) != 0 | ||
|
|
||
|
|
||
| def test_root_path_validation_checks_intermediate_symlinks_before_dot_segments(tmp_path): | ||
| target = tmp_path / "target" | ||
| target.mkdir() | ||
| (target / "nested").mkdir() | ||
| link = tmp_path / "link" | ||
| link.symlink_to(target, target_is_directory=True) | ||
| assert _validate(str(link / "nested" / "config.env")) != 0 | ||
| assert _validate(str(link) + "/../config.env") != 0 | ||
| assert _validate(str(target / "nested" / "config.env")) == 0 | ||
| assert _validate(str(tmp_path / "new" / "config.env")) == 0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the image is run with
--user 10001or a KubernetesrunAsUserand/data/.engraphis/config.envhas not already been provisioned, the root-only entrypoint block never creates this newly explicit config file. Becauseconfig._load_trusted_dotenv()treats an explicitENGRAPHIS_ENV_FILEas required (allow_missing=False), importing the configuration raisesFileNotFoundErrorand the dashboard exits before serving; ensure the non-root path creates the file when writable or tolerates its initial absence.Useful? React with 👍 / 👎.