Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ ENV PYTHONUNBUFFERED=1 \
# Customer-side cloud session and entitlement display cache. Keep it on /data rather
# than the container's ephemeral home so reconnects do not lose rotated credentials.
# License issuance, trial state, leases, and revocations remain private services.
ENGRAPHIS_STATE_DIR=/data/.engraphis
ENGRAPHIS_STATE_DIR=/data/.engraphis \
# Dashboard-managed non-secret settings must survive a Railway redeploy with the volume.
ENGRAPHIS_ENV_FILE=/data/.engraphis/config.env

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow first boot when the container starts non-root

When the image is run with --user 10001 or a Kubernetes runAsUser and /data/.engraphis/config.env has not already been provisioned, the root-only entrypoint block never creates this newly explicit config file. Because config._load_trusted_dotenv() treats an explicit ENGRAPHIS_ENV_FILE as required (allow_missing=False), importing the configuration raises FileNotFoundError and the dashboard exits before serving; ensure the non-root path creates the file when writable or tolerates its initial absence.

Useful? React with 👍 / 👎.


WORKDIR /app

Expand All @@ -33,6 +35,8 @@ RUN apt-get update \
COPY pyproject.toml README.md LICENSE NOTICE ./
COPY engraphis ./engraphis
COPY scripts ./scripts
# The declared distribution license assets are part of the package build metadata.
COPY deploy ./deploy

# Railway runs CPU workloads. Install the CPU-only PyTorch wheel before the embedding
# stack so pip cannot select PyPI's multi-gigabyte CUDA dependency chain. The public
Expand Down
9 changes: 9 additions & 0 deletions deploy/railway-template.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
}
},
"variables": {
"ENGRAPHIS_HOST": {
"value": "0.0.0.0",
"prompt": "Bind the public Railway service on all IPv4 interfaces so the platform PORT and health probe are reachable.",
"required": true
},
"ENGRAPHIS_SERVICE_MODE": {
"value": "customer",
"required": true
Expand All @@ -27,6 +32,10 @@
"value": "/data/.engraphis",
"required": true
},
"ENGRAPHIS_ENV_FILE": {
"value": "/data/.engraphis/config.env",
"required": true
},
"ENGRAPHIS_API_TOKEN": {
"value": "${{ secret(48) }}",
"secret": true,
Expand Down
276 changes: 267 additions & 9 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
# 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 repeating ownership writes on a correctly owned volume.
# A non-root launch initializes private settings in its already-writable state volume.
set -e
umask 077

# Default bind host, decided at runtime (not baked into the image). Uvicorn's `::`
# listener is IPv6-only on some container kernels, so plain Docker port forwarding cannot
Expand All @@ -26,13 +27,270 @@ if [ -z "${ENGRAPHIS_HOST:-}" ]; then
export ENGRAPHIS_HOST
fi

# 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
}

state_directory_is_owned() {
# Only /data is an ownership-repair target. External state paths must be
# provisioned for the app beforehand, including on the first boot.
case "$1" in
/data|/data/*) return 0 ;;
esac
[ -d "$1" ] && [ "$(stat -c '%u' "$1" 2>/dev/null)" = "$2" ]
}

create_private_runtime_directory() {
if ! reject_linked_path "$1"; then
return 1
fi
create_remaining=${1#/}
create_current=
while [ -n "$create_remaining" ]; do
case "$create_remaining" in
*/*)
create_component=${create_remaining%%/*}
create_remaining=${create_remaining#*/}
;;
*)
create_component=$create_remaining
create_remaining=
;;
esac
case "$create_component" in ""|.) continue ;; esac
create_current="$create_current/$create_component"
if [ ! -e "$create_current" ]; then
# Never chown an existing ancestor. Each new directory must become
# traversable by the app before another private child is created.
if ! mkdir "$create_current" || ! reject_linked_path "$create_current" \
|| [ ! -d "$create_current" ] \
|| ! chown engraphis:engraphis "$create_current"; then
return 1
fi
elif [ ! -d "$create_current" ] || [ -L "$create_current" ]; then
return 1
fi
done
}

repair_volume_descendants() {
# Restores can preserve the marker while resetting file ownership. Scan for
# a mismatch before trusting it; do not follow links outside the volume.
unowned_entry=$(find "$1" ! -uid "$2" -print -quit) || return 1
if [ -n "$unowned_entry" ]; then
chown -R -h engraphis:engraphis "$1" || return 1
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
# ENGRAPHIS_STATE_DIR defaults to /data/.engraphis. Repair the complete volume only on
# first boot or ownership drift; restarts scan without rewriting correct ownership.
state_dir="${ENGRAPHIS_STATE_DIR:-/data/.engraphis}"
# Keep the marker on the volume it describes; external state may outlive a
# replaced /data volume that still needs its first ownership repair.
ownership_marker="/data/.volume-ownership"
config_file="${ENGRAPHIS_ENV_FILE:-}"
app_owner=$(id -u engraphis)
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 ! state_directory_is_owned "$state_dir" "$app_owner"; then
printf '%s\n' "[engraphis] external state directory must already be owned by engraphis: $state_dir" >&2
exit 1
fi
if ! create_private_runtime_directory "$state_dir"; then
printf '%s\n' "[engraphis] unable to create state directory: $state_dir" >&2
exit 1
fi
if ! reject_linked_path "$state_dir" || [ ! -d "$state_dir" ] || ! chmod 700 "$state_dir"; then
printf '%s\n' "[engraphis] unable to restrict 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
Comment thread
Coding-Dev-Tools marked this conversation as resolved.
printf '%s\n' "[engraphis] refusing symlinked trusted config directory: $config_parent" >&2
exit 1
fi
if [ -e "$config_parent" ] && [ ! -d "$config_parent" ]; then
printf '%s\n' "[engraphis] refusing non-directory trusted config parent: $config_parent" >&2
exit 1
fi
if ! create_private_runtime_directory "$config_parent"; then
printf '%s\n' "[engraphis] unable to create config directory: $config_parent" >&2
exit 1
fi
# Check external existing parents before creating or changing any file.
# Parents under /data receive the volume's first-boot ownership repair.
case "$config_parent" in
/data|/data/*) ;;
*)
config_owner=$(stat -c '%u' "$config_parent" 2>/dev/null || true)
if [ "$config_owner" != "$app_owner" ]; then
printf '%s\n' "[engraphis] trusted config directory must be owned by engraphis: $config_parent" >&2
exit 1
fi
;;
esac
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" ] \
|| [ "$(stat -c '%h' "$config_file" 2>/dev/null)" != "1" ]; then
printf '%s\n' "[engraphis] refusing changed, hard-linked, 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 -h engraphis:engraphis /data; then
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 [ "$(stat -c '%h' "$ownership_marker" 2>/dev/null)" != "1" ]; then
printf '%s\n' "[engraphis] refusing hard-linked volume ownership marker: $ownership_marker" >&2
exit 1
elif ! repair_volume_descendants /data "$app_owner" \
|| ! chown engraphis:engraphis /data "$state_dir" "$ownership_marker"; then
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)
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" ] \
|| [ "$(stat -c '%h' "$config_file" 2>/dev/null)" != "1" ]; 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

# Explicit trusted settings are required by the configuration loader. A rootless
# container with a writable volume must provision them before the app imports it.
state_dir="${ENGRAPHIS_STATE_DIR:-/data/.engraphis}"
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
if ! mkdir -p "$state_dir" || ! reject_linked_path "$state_dir" || [ ! -d "$state_dir" ] || ! chmod 700 "$state_dir"; then
printf '%s\n' "[engraphis] unable to initialize private 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 ! mkdir -p "$config_parent" || ! reject_linked_path "$config_parent" || [ ! -d "$config_parent" ]; then
printf '%s\n' "[engraphis] unable to initialize trusted config directory: $config_parent" >&2
exit 1
fi
config_owner=$(stat -c '%u' "$config_parent" 2>/dev/null || true)
if [ "$config_owner" != "$(id -u)" ]; then
printf '%s\n' "[engraphis] trusted config directory must belong to the runtime user: $config_parent" >&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" ] \
|| [ "$(stat -c '%h' "$config_file" 2>/dev/null)" != "1" ] || ! chmod 600 "$config_file"; then
printf '%s\n' "[engraphis] unable to restrict trusted config file: $config_file" >&2
exit 1
fi
fi

exec "$@"
14 changes: 14 additions & 0 deletions docs/DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ ENGRAPHIS_COMPOSE_PORT=8787

Then open `http://127.0.0.1:8787`. License issuance, trials, leases, and revocations remain on the private control plane.

The root entrypoint repairs ownership only for the managed `/data` volume. When a custom
container configuration places `ENGRAPHIS_STATE_DIR` outside `/data`, create that directory
with the container's `engraphis` UID as owner and provision its contents for that UID before
startup; external descendants are never recursively repaired. Existing external config parent
directories must also belong to that UID; startup rejects other owners before changing files.
The repair marker lives at `/data/.volume-ownership`, so reusing external state cannot skip
the repair of a replaced data volume. Restarts scan for ownership drift before trusting an
existing marker, repairing restored files when needed without following external symlinks.
Correctly owned volumes avoid recursive ownership writes, but still require a metadata scan.
State directories use mode `0700`; trusted settings must have a single hard link and use
mode `0600`. A container started with
`--user` or `runAsUser` initializes a missing settings file when its state volume is writable
by that runtime UID; it preserves the file on restart.

> Port precedence: the dashboard binds `$PORT` when the platform injects one, falling back
> to `ENGRAPHIS_PORT` (then `8700`). Compose sets both from `ENGRAPHIS_COMPOSE_PORT` so the
> published host port and the in-container bind stay in sync; a stray desktop `ENGRAPHIS_PORT`
Expand Down
7 changes: 7 additions & 0 deletions docs/HOSTING_RAILWAY.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,20 @@ Use the `Dockerfile`, mount a private persistent volume at `/data`, and configur

```dotenv
ENGRAPHIS_SERVICE_MODE=customer
ENGRAPHIS_HOST=0.0.0.0
ENGRAPHIS_DB_PATH=/data/engraphis.db
ENGRAPHIS_STATE_DIR=/data/.engraphis
ENGRAPHIS_ENV_FILE=/data/.engraphis/config.env
ENGRAPHIS_API_TOKEN=<strong-random-secret>
ENGRAPHIS_JSON_LOGS=1
ENGRAPHIS_FORWARDED_ALLOW_IPS=*
```

Keep the image entrypoint and default command (`engraphis-dashboard --no-open`) in place. A
Railway service-level Start Command override can bypass `docker-entrypoint.sh`, which is
responsible for volume ownership repair, and can leave the app bound only to loopback. Clear
old Start Command overrides before deploying this image.

Set `ENGRAPHIS_FORWARDED_ALLOW_IPS=*` only when the container is reachable exclusively through
Railway's trusted proxy. Set the dashboard's public URL where the runtime supports it, terminate
TLS at the platform edge, and keep the volume private.
Expand Down
2 changes: 2 additions & 0 deletions docs/RAILWAY_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ issuer, relay, managed compute, Auto Dreaming, Auto Consolidation, or Team ident

- Source: `Coding-Dev-Tools/engraphis`, branch `main`, `Dockerfile` build.
- Service mode: `customer`.
- Bind host: `0.0.0.0` so Railway's injected `PORT` and public health probes reach the process.
- Persistent volume: `/data`.
- Trusted runtime settings: `/data/.engraphis/config.env` on the persistent volume.
- Health check: `/api/ready`.
- `ENGRAPHIS_DASHBOARD_URL` derived from Railway's generated public domain (override it with the
canonical HTTPS custom domain once one is active so public MCP origin checks remain strict).
Expand Down
Loading
Loading