mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-02 21:04:06 +03:00
fix(workspace): stabilise the development stack (#2024)
This commit is contained in:
+10
-32
@@ -7,7 +7,6 @@ ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
ARG NODE_MAJOR=24
|
||||
ARG ELP_VERSION=2026-02-27
|
||||
ARG HELM_VERSION=4.2.0
|
||||
ARG PNPM_VERSION=10.29.3
|
||||
ARG WASM_BINDGEN_VERSION=0.2.122
|
||||
|
||||
@@ -15,8 +14,8 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
acl \
|
||||
bash \
|
||||
brotli \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
clang \
|
||||
@@ -42,13 +41,13 @@ RUN apt-get update \
|
||||
libfido2-dev \
|
||||
libgbm1 \
|
||||
libgtk-3-0 \
|
||||
libimage-exiftool-perl \
|
||||
libnotify4 \
|
||||
libnss3 \
|
||||
libpipewire-0.3-dev \
|
||||
libpulse-dev \
|
||||
libsecret-1-0 \
|
||||
libudev-dev \
|
||||
libuv1-dev \
|
||||
libcurl4-openssl-dev \
|
||||
libswresample-dev \
|
||||
libswscale-dev \
|
||||
@@ -71,14 +70,12 @@ RUN apt-get update \
|
||||
ninja-build \
|
||||
openssh-client \
|
||||
pkg-config \
|
||||
protobuf-compiler \
|
||||
python3 \
|
||||
python3-pip \
|
||||
rsync \
|
||||
python3-venv \
|
||||
sudo \
|
||||
rpm \
|
||||
unzip \
|
||||
webp \
|
||||
xz-utils \
|
||||
xdg-utils \
|
||||
zstd \
|
||||
@@ -90,6 +87,7 @@ RUN apt-get update \
|
||||
bat \
|
||||
btop \
|
||||
docker-cli \
|
||||
docker-compose \
|
||||
dnsutils \
|
||||
fd-find \
|
||||
gdb \
|
||||
@@ -122,24 +120,12 @@ RUN apt-get update \
|
||||
&& ln -sf /usr/bin/batcat /usr/local/bin/bat \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
||||
RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& corepack enable
|
||||
|
||||
RUN ARCH="$(dpkg --print-architecture)" \
|
||||
&& case "$ARCH" in \
|
||||
amd64) HELM_ARCH="amd64" ;; \
|
||||
arm64) HELM_ARCH="arm64" ;; \
|
||||
*) echo "Unsupported architecture for Helm: $ARCH" >&2; exit 1 ;; \
|
||||
esac \
|
||||
&& curl -fsSL "https://get.helm.sh/helm-v${HELM_VERSION}-linux-${HELM_ARCH}.tar.gz" -o /tmp/helm.tgz \
|
||||
&& tar -C /tmp -xzf /tmp/helm.tgz "linux-${HELM_ARCH}/helm" \
|
||||
&& mv "/tmp/linux-${HELM_ARCH}/helm" /usr/local/bin/helm \
|
||||
&& chmod +x /usr/local/bin/helm \
|
||||
&& rm -rf /tmp/helm.tgz "/tmp/linux-${HELM_ARCH}"
|
||||
|
||||
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli cqlsh
|
||||
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli
|
||||
|
||||
COPY tools/fonts/requirements.txt /tmp/fluxer-fonts-requirements.txt
|
||||
RUN python3 -m pip install --break-system-packages --no-cache-dir -r /tmp/fluxer-fonts-requirements.txt \
|
||||
@@ -147,18 +133,13 @@ RUN python3 -m pip install --break-system-packages --no-cache-dir -r /tmp/fluxer
|
||||
&& pyftsubset --help >/dev/null \
|
||||
&& python3 -c "import fontTools, brotli"
|
||||
|
||||
RUN if ! command -v rebar3 >/dev/null 2>&1; then \
|
||||
curl -fsSL https://s3.amazonaws.com/rebar3/rebar3 -o /usr/local/bin/rebar3 \
|
||||
&& chmod +x /usr/local/bin/rebar3; \
|
||||
fi
|
||||
|
||||
RUN ARCH="$(dpkg --print-architecture)" \
|
||||
&& case "$ARCH" in \
|
||||
amd64) ELP_ARCH="x86_64" ;; \
|
||||
arm64) ELP_ARCH="aarch64" ;; \
|
||||
*) echo "Unsupported architecture for ELP: $ARCH" >&2; exit 1 ;; \
|
||||
esac \
|
||||
&& curl -fsSL "https://github.com/WhatsApp/erlang-language-platform/releases/download/${ELP_VERSION}/elp-linux-${ELP_ARCH}-unknown-linux-gnu-otp-28.tar.gz" -o /tmp/elp.tgz \
|
||||
&& curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL "https://github.com/WhatsApp/erlang-language-platform/releases/download/${ELP_VERSION}/elp-linux-${ELP_ARCH}-unknown-linux-gnu-otp-28.tar.gz" -o /tmp/elp.tgz \
|
||||
&& tar -C /usr/local/bin -xzf /tmp/elp.tgz elp \
|
||||
&& chmod +x /usr/local/bin/elp \
|
||||
&& rm /tmp/elp.tgz
|
||||
@@ -179,16 +160,13 @@ ENV DOCKER_HOST="unix:///var/run/docker.sock" \
|
||||
ENV CC_wasm32_unknown_unknown="clang" \
|
||||
AR_wasm32_unknown_unknown="llvm-ar"
|
||||
|
||||
RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile default --component clippy,rustfmt \
|
||||
RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --component clippy,rustfmt \
|
||||
&& rustup target add wasm32-unknown-unknown \
|
||||
&& cargo install cargo-watch --locked \
|
||||
&& cargo install wasm-bindgen-cli --version "${WASM_BINDGEN_VERSION}" --locked
|
||||
&& cargo install wasm-bindgen-cli --version "${WASM_BINDGEN_VERSION}" --locked \
|
||||
&& rm -rf "/home/${USERNAME}/.cargo/registry" "/home/${USERNAME}/.cargo/git"
|
||||
|
||||
RUN corepack prepare "pnpm@${PNPM_VERSION}" --activate \
|
||||
&& pnpm --version
|
||||
|
||||
RUN sudo apt-get update \
|
||||
&& sudo apt-get install -y --no-install-recommends python3-venv \
|
||||
&& sudo rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /workspaces/fluxer
|
||||
|
||||
@@ -5,20 +5,17 @@
|
||||
"workspaceFolder": "/workspaces/fluxer",
|
||||
"shutdownAction": "stopCompose",
|
||||
"remoteUser": "vscode",
|
||||
"hostRequirements": {
|
||||
"cpus": 4,
|
||||
"memory": "8gb",
|
||||
"storage": "32gb"
|
||||
},
|
||||
"remoteEnv": {
|
||||
"DOCKER_HOST": "unix:///var/run/docker.sock"
|
||||
},
|
||||
"runServices": ["workspace", "postgres", "valkey", "nats", "livekit", "meilisearch", "mailpit"],
|
||||
"forwardPorts": [
|
||||
3000, 8088, 8080, 8771, 8082, 3010, 3020, 8100, 8101, 8102, 8103, 8104, 8105, 8106, 8107, 8108, 8109, 8110, 8111,
|
||||
8112, 8113, 8114, 8115, 8116, 8117, 8118, 8119, 8120, 8121, 8122, 8123, 8124, 8125, 3900, 8888, 9333, 9340, 23646,
|
||||
4222, 7700, 7880, 7900, 9200, 8000
|
||||
],
|
||||
"forwardPorts": [3000, 8088, 8080, 8771, 8082, 8773, 3010, 3020, 8333],
|
||||
"portsAttributes": {
|
||||
"8000": {
|
||||
"label": "Zensical docs",
|
||||
"onAutoForward": "openBrowserOnce"
|
||||
},
|
||||
"8088": {
|
||||
"label": "Fluxer dev proxy",
|
||||
"onAutoForward": "notify"
|
||||
@@ -29,36 +26,15 @@
|
||||
"3020": {
|
||||
"label": "Fluxer admin"
|
||||
},
|
||||
"8100": {
|
||||
"label": "Fluxer Rust service health"
|
||||
},
|
||||
"3900": {
|
||||
"8333": {
|
||||
"label": "SeaweedFS S3"
|
||||
},
|
||||
"8888": {
|
||||
"label": "SeaweedFS filer"
|
||||
},
|
||||
"9333": {
|
||||
"label": "SeaweedFS master"
|
||||
},
|
||||
"9340": {
|
||||
"label": "SeaweedFS volume"
|
||||
},
|
||||
"23646": {
|
||||
"label": "SeaweedFS admin"
|
||||
},
|
||||
"7880": {
|
||||
"label": "LiveKit"
|
||||
},
|
||||
"7700": {
|
||||
"label": "Meilisearch"
|
||||
},
|
||||
"9200": {
|
||||
"label": "Elasticsearch"
|
||||
"8773": {
|
||||
"label": "Fluxer app proxy"
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "sudo chown -R vscode:vscode /workspaces/fluxer/target && find /workspaces/fluxer -maxdepth 4 -type d -name node_modules -prune -exec sudo chown -R vscode:vscode {} + && sudo chown -R vscode:vscode /home/vscode/.local/share/pnpm && cargo run -p fluxer-dev -- bootstrap",
|
||||
"postStartCommand": "bash /workspaces/fluxer/.devcontainer/fix-docker-socket.sh && cargo run -p fluxer-dev -- post-start && bash /workspaces/fluxer/fluxer_docs/serve.sh --daemon",
|
||||
"postCreateCommand": "bash /workspaces/fluxer/.devcontainer/fix-docker-socket.sh && bash /workspaces/fluxer/.devcontainer/fix-workspace-permissions.sh && cargo run -p fluxer-dev -- bootstrap",
|
||||
"postStartCommand": "bash /workspaces/fluxer/.devcontainer/fix-docker-socket.sh && bash /workspaces/fluxer/.devcontainer/fix-workspace-permissions.sh && cargo run -p fluxer-dev -- post-start",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
name: fluxer-dev
|
||||
|
||||
services:
|
||||
workspace:
|
||||
build:
|
||||
@@ -7,15 +5,29 @@ services:
|
||||
dockerfile: .devcontainer/Dockerfile
|
||||
command: sleep infinity
|
||||
init: true
|
||||
env_file:
|
||||
- ../config/env/development.env
|
||||
environment:
|
||||
FLUXER_SEARCH_ENGINE: meilisearch
|
||||
FLUXER_SEARCH_URL: http://meilisearch:7700
|
||||
FLUXER_SEARCH_API_KEY: fluxer-dev-meilisearch
|
||||
FLUXER_POSTGRES_HOST: postgres
|
||||
FLUXER_SELF_HOSTED: "true"
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
npm_config_store_dir: /home/vscode/.local/share/pnpm/store
|
||||
FLUXER_PUBLIC_PORT: "${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
FLUXER_PUBLIC_URL: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
FLUXER_API_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
|
||||
FLUXER_API_CLIENT_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
|
||||
FLUXER_APP_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
FLUXER_GATEWAY_ENDPOINT: "ws://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/gateway"
|
||||
FLUXER_MEDIA_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
|
||||
FLUXER_STATIC_CDN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
FLUXER_ADMIN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/admin"
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
|
||||
FLUXER_S3_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
FLUXER_LIVEKIT_URL: "ws://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/livekit"
|
||||
FLUXER_LIVEKIT_INTERNAL_URL: "http://livekit:7880"
|
||||
FLUXER_LIVEKIT_WEBHOOK_URL: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api/webhooks/livekit"
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
|
||||
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
|
||||
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
FLUXER_ADMIN_OAUTH_REDIRECT_URI: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/admin/oauth2_callback"
|
||||
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: "http://localhost,http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
|
||||
volumes:
|
||||
- ..:/workspaces/fluxer:cached
|
||||
- type: volume
|
||||
@@ -229,6 +241,7 @@ services:
|
||||
volume:
|
||||
nocopy: true
|
||||
- pnpm-store:/home/vscode/.local/share/pnpm/store
|
||||
- docs-venv:/workspaces/fluxer/fluxer_docs/.venv
|
||||
- cargo-registry:/home/vscode/.cargo/registry
|
||||
- cargo-git:/home/vscode/.cargo/git
|
||||
- rust-target:/workspaces/fluxer/target
|
||||
@@ -236,45 +249,31 @@ services:
|
||||
source: ${FLUXER_DOCKER_SOCKET:-/var/run/docker.sock}
|
||||
target: /var/run/docker.sock
|
||||
ports:
|
||||
- "${FLUXER_DEV_DOCS_PORT:-8000}:8000"
|
||||
- "${FLUXER_DEV_RSPACK_PORT:-3000}:3000"
|
||||
- "${FLUXER_DEV_PROXY_PORT:-8088}:8088"
|
||||
- "${FLUXER_DEV_APP_PROXY_PORT:-8080}:8080"
|
||||
- "${FLUXER_DEV_API_PORT:-8771}:8771"
|
||||
- "${FLUXER_DEV_GATEWAY_PORT:-8082}:8082"
|
||||
- "${FLUXER_DEV_MARKETING_PORT:-3010}:3010"
|
||||
- "${FLUXER_DEV_ADMIN_PORT:-3020}:3020"
|
||||
- "${FLUXER_DEV_RUST_SERVICE_PORTS:-8100-8125}:8100-8125"
|
||||
- "${FLUXER_DEV_SEAWEEDFS_S3_PORT:-3900}:8333"
|
||||
- "${FLUXER_DEV_SEAWEEDFS_FILER_PORT:-8888}:8888"
|
||||
- "${FLUXER_DEV_SEAWEEDFS_MASTER_PORT:-9333}:9333"
|
||||
- "${FLUXER_DEV_SEAWEEDFS_VOLUME_PORT:-9340}:9340"
|
||||
- "${FLUXER_DEV_SEAWEEDFS_ADMIN_PORT:-23646}:23646"
|
||||
- "127.0.0.1:${FLUXER_DEV_RSPACK_PORT:-3000}:3000"
|
||||
- "127.0.0.1:${FLUXER_DEV_PROXY_PORT:-8088}:8088"
|
||||
- "127.0.0.1:${FLUXER_DEV_API_PORT:-8080}:8080"
|
||||
- "127.0.0.1:${FLUXER_DEV_GATEWAY_PORT:-8771}:8771"
|
||||
- "127.0.0.1:${FLUXER_DEV_MEDIA_PROXY_PORT:-8082}:8082"
|
||||
- "127.0.0.1:${FLUXER_DEV_APP_PROXY_PORT:-8773}:8773"
|
||||
- "127.0.0.1:${FLUXER_DEV_MARKETING_PORT:-3010}:3010"
|
||||
- "127.0.0.1:${FLUXER_DEV_ADMIN_PORT:-3020}:3020"
|
||||
- "127.0.0.1:${FLUXER_DEV_SEAWEEDFS_S3_PORT:-3900}:8333"
|
||||
depends_on:
|
||||
- postgres
|
||||
- valkey
|
||||
- nats
|
||||
- livekit
|
||||
- meilisearch
|
||||
- mailpit
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_started
|
||||
nats:
|
||||
condition: service_started
|
||||
livekit:
|
||||
condition: service_started
|
||||
meilisearch:
|
||||
condition: service_healthy
|
||||
mailpit:
|
||||
condition: service_started
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
cassandra:
|
||||
image: cassandra:5.0.8
|
||||
profiles:
|
||||
- full
|
||||
environment:
|
||||
CASSANDRA_CLUSTER_NAME: fluxer-dev
|
||||
CASSANDRA_DC: datacenter1
|
||||
CASSANDRA_ENDPOINT_SNITCH: GossipingPropertyFileSnitch
|
||||
HEAP_NEWSIZE: 128M
|
||||
MAX_HEAP_SIZE: 768M
|
||||
volumes:
|
||||
- cassandra-data:/var/lib/cassandra
|
||||
ports:
|
||||
- "${FLUXER_DEV_CASSANDRA_PORT:-9042}:9042"
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
@@ -284,13 +283,19 @@ services:
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${FLUXER_DEV_POSTGRES_PORT:-5432}:5432"
|
||||
- "127.0.0.1:${FLUXER_DEV_POSTGRES_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U fluxer -d fluxer"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 5s
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8.1.7-alpine
|
||||
command: ["valkey-server", "--save", "", "--appendonly", "no"]
|
||||
ports:
|
||||
- "${FLUXER_DEV_VALKEY_PORT:-6379}:6379"
|
||||
- "127.0.0.1:${FLUXER_DEV_VALKEY_PORT:-6379}:6379"
|
||||
|
||||
nats:
|
||||
image: nats:2.14.2-alpine
|
||||
@@ -298,33 +303,22 @@ services:
|
||||
volumes:
|
||||
- nats-data:/data
|
||||
ports:
|
||||
- "${FLUXER_DEV_NATS_PORT:-4222}:4222"
|
||||
- "${FLUXER_DEV_NATS_MONITOR_PORT:-8222}:8222"
|
||||
- "127.0.0.1:${FLUXER_DEV_NATS_PORT:-4222}:4222"
|
||||
- "127.0.0.1:${FLUXER_DEV_NATS_MONITOR_PORT:-8222}:8222"
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.12.0
|
||||
command: ["--config", "/etc/livekit.yaml", "--bind", "0.0.0.0"]
|
||||
environment:
|
||||
LIVEKIT_RTC_TCP_PORT: "${FLUXER_DEV_LIVEKIT_TCP_PORT:-7881}"
|
||||
LIVEKIT_RTC_UDP_PORT_START: "${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}"
|
||||
LIVEKIT_RTC_UDP_PORT_END: "${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}"
|
||||
volumes:
|
||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||
ports:
|
||||
- "${FLUXER_DEV_LIVEKIT_PORT:-7880}:7880"
|
||||
- "${FLUXER_DEV_LIVEKIT_TCP_PORT:-7881}:7881"
|
||||
- "${FLUXER_DEV_LIVEKIT_UDP_PORTS:-7882-7892}:7882-7892/udp"
|
||||
|
||||
elasticsearch:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:9.3.2
|
||||
profiles:
|
||||
- full
|
||||
environment:
|
||||
discovery.type: single-node
|
||||
xpack.security.enabled: "true"
|
||||
xpack.security.http.ssl.enabled: "false"
|
||||
ELASTIC_PASSWORD: fluxer-dev-elasticsearch
|
||||
ES_JAVA_OPTS: "-Xms512m -Xmx512m"
|
||||
volumes:
|
||||
- elasticsearch-data:/usr/share/elasticsearch/data
|
||||
ports:
|
||||
- "${FLUXER_DEV_ELASTICSEARCH_PORT:-9200}:9200"
|
||||
- "127.0.0.1:${FLUXER_DEV_LIVEKIT_PORT:-7880}:7880"
|
||||
- "127.0.0.1:${FLUXER_DEV_LIVEKIT_TCP_PORT:-7881}:${FLUXER_DEV_LIVEKIT_TCP_PORT:-7881}"
|
||||
- "127.0.0.1:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}/udp"
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.12
|
||||
@@ -334,7 +328,13 @@ services:
|
||||
volumes:
|
||||
- meilisearch-data:/meili_data
|
||||
ports:
|
||||
- "${FLUXER_DEV_MEILISEARCH_PORT:-7700}:7700"
|
||||
- "127.0.0.1:${FLUXER_DEV_MEILISEARCH_PORT:-7700}:7700"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:7700/health"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 5s
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.30
|
||||
@@ -349,6 +349,7 @@ services:
|
||||
|
||||
volumes:
|
||||
pnpm-store:
|
||||
docs-venv:
|
||||
root-node-modules:
|
||||
fluxer-api-node-modules:
|
||||
fluxer-app-node-modules:
|
||||
@@ -394,9 +395,7 @@ volumes:
|
||||
cargo-registry:
|
||||
cargo-git:
|
||||
rust-target:
|
||||
cassandra-data:
|
||||
nats-data:
|
||||
elasticsearch-data:
|
||||
meilisearch-data:
|
||||
mailpit-data:
|
||||
postgres-data:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
set -uo pipefail
|
||||
set -euo pipefail
|
||||
|
||||
SOCKET="${DOCKER_SOCKET:-/var/run/docker.sock}"
|
||||
USER_NAME="${USER:-vscode}"
|
||||
USER_NAME="$(id -un)"
|
||||
|
||||
if [ ! -S "$SOCKET" ]; then
|
||||
echo "fix-docker-socket: no socket at $SOCKET; skipping (Docker-in-devcontainer will not work)"
|
||||
@@ -16,31 +16,11 @@ if docker version --format '{{.Server.Version}}' >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
socket_gid="$(stat -c '%g' "$SOCKET" 2>/dev/null || echo "")"
|
||||
if [ -z "$socket_gid" ]; then
|
||||
echo "fix-docker-socket: could not stat $SOCKET; skipping" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sudo sh -c '
|
||||
set -e
|
||||
gid="$1"
|
||||
user="$2"
|
||||
socket="$3"
|
||||
if ! getent group "$gid" >/dev/null 2>&1; then
|
||||
groupadd --gid "$gid" docker-host
|
||||
fi
|
||||
group_name="$(getent group "$gid" | cut -d: -f1)"
|
||||
usermod --append --groups "$group_name" "$user"
|
||||
chgrp "$gid" "$socket"
|
||||
chmod g+rw "$socket"
|
||||
' sh "$socket_gid" "$USER_NAME" "$SOCKET" || {
|
||||
echo "fix-docker-socket: could not adjust $SOCKET; run docker with sudo" >&2
|
||||
exit 0
|
||||
}
|
||||
sudo setfacl --modify "user:${USER_NAME}:rw" "$SOCKET"
|
||||
|
||||
if docker version --format '{{.Server.Version}}' >/dev/null 2>&1; then
|
||||
echo "fix-docker-socket: $SOCKET is now usable as $USER_NAME (gid $socket_gid)"
|
||||
echo "fix-docker-socket: $SOCKET is now usable as $USER_NAME"
|
||||
else
|
||||
echo "fix-docker-socket: $SOCKET still unreachable as $USER_NAME; run docker with sudo" >&2
|
||||
echo "fix-docker-socket: $SOCKET is still unreachable as $USER_NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
owner="$(id -u):$(id -g)"
|
||||
|
||||
repair_tree() {
|
||||
local path="$1"
|
||||
local unwritable
|
||||
if [ ! -d "$path" ]; then
|
||||
echo "fix-workspace-permissions: expected mount is missing: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
unwritable="$(find "$path" -xdev \( -type d -o -type f \) ! -writable -print -quit 2>/dev/null || true)"
|
||||
if [ ! -w "$path" ] || [ -n "$unwritable" ]; then
|
||||
sudo find "$path" -xdev \( -type d -o -type f \) -exec chown "$owner" {} +
|
||||
fi
|
||||
unwritable="$(find "$path" -xdev \( -type d -o -type f \) ! -writable -print -quit 2>/dev/null || true)"
|
||||
if [ ! -w "$path" ] || [ -n "$unwritable" ]; then
|
||||
echo "fix-workspace-permissions: $path is not writable as $(id -un)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
for path in \
|
||||
/workspaces/fluxer/target \
|
||||
/home/vscode/.cargo/registry \
|
||||
/home/vscode/.cargo/git \
|
||||
/home/vscode/.local \
|
||||
/home/vscode/.local/share/pnpm/store \
|
||||
/workspaces/fluxer/fluxer_docs/.venv; do
|
||||
repair_tree "$path"
|
||||
done
|
||||
|
||||
while IFS= read -r -d '' path; do
|
||||
if mountpoint -q "$path"; then
|
||||
repair_tree "$path"
|
||||
fi
|
||||
done < <(find /workspaces/fluxer -maxdepth 4 -type d -name node_modules -prune -print0)
|
||||
@@ -1,11 +1,10 @@
|
||||
port: 7880
|
||||
|
||||
keys:
|
||||
devkey: secret
|
||||
devkey: fluxer-livekit-development-secret
|
||||
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
udp_port: 7882-7892
|
||||
node_ip: 127.0.0.1
|
||||
use_mdns: true
|
||||
stun_servers:
|
||||
|
||||
Vendored
+2
-7
@@ -30,12 +30,6 @@ FLUXER_POSTGRES_PASSWORD=fluxer
|
||||
FLUXER_POSTGRES_SSL=false
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS=20
|
||||
FLUXER_POSTGRES_KV_TABLE=fluxer_kv
|
||||
FLUXER_CASSANDRA_HOSTS=cassandra
|
||||
FLUXER_CASSANDRA_PORT=9042
|
||||
FLUXER_CASSANDRA_KEYSPACE=fluxer
|
||||
FLUXER_CASSANDRA_LOCAL_DC=datacenter1
|
||||
FLUXER_CASSANDRA_USERNAME=fluxer
|
||||
FLUXER_CASSANDRA_PASSWORD=fluxer
|
||||
FLUXER_KV_URL=redis://valkey:6379/0
|
||||
FLUXER_NATS_URL=nats://nats:4222
|
||||
FLUXER_NATS_JETSTREAM_URL=nats://nats:4222
|
||||
@@ -66,8 +60,9 @@ FLUXER_S3_BUCKET_STATIC=fluxer-static
|
||||
|
||||
FLUXER_LIVEKIT_ENABLED=true
|
||||
FLUXER_LIVEKIT_URL=ws://localhost:8088/livekit
|
||||
FLUXER_LIVEKIT_INTERNAL_URL=http://localhost:7880
|
||||
FLUXER_LIVEKIT_API_KEY=devkey
|
||||
FLUXER_LIVEKIT_API_SECRET=secret
|
||||
FLUXER_LIVEKIT_API_SECRET=fluxer-livekit-development-secret
|
||||
FLUXER_LIVEKIT_WEBHOOK_URL=http://localhost:8088/api/webhooks/livekit
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION={"id":"local","name":"Local","emoji":"LC","latitude":59.3293,"longitude":18.0686}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_LIVEKIT_ENABLED: "true"
|
||||
FLUXER_LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}
|
||||
FLUXER_LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?set LIVEKIT_API_SECRET in .env}
|
||||
FLUXER_LIVEKIT_INTERNAL_URL: http://livekit:7880
|
||||
FLUXER_LIVEKIT_WEBHOOK_URL: http://api:8080/webhooks/livekit
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION: '{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}'
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ function AbuseAwareAppErrorHandler(err: Error, ctx: Context<HonoEnv>): Response
|
||||
|
||||
export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIAppResult> {
|
||||
const {config, logger} = options;
|
||||
const shutdownApiLifecycle = createShutdown(logger);
|
||||
const shutdownApiLifecycle = createShutdown(config, logger);
|
||||
setIsDevelopment(config.nodeEnv === 'development');
|
||||
const routes = new Hono<HonoEnv>({strict: true});
|
||||
configureMiddleware(routes, {
|
||||
|
||||
@@ -306,6 +306,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
apiSecret: master.integrations.voice.api_secret,
|
||||
webhookUrl: master.integrations.voice.webhook_url,
|
||||
url: master.integrations.voice.url,
|
||||
internalUrl: master.integrations.voice.internal_url,
|
||||
defaultRegion: master.integrations.voice.default_region,
|
||||
},
|
||||
stripe: {
|
||||
|
||||
@@ -36,6 +36,11 @@ import {JetStreamWorkerQueue} from '../worker/JetStreamWorkerQueue';
|
||||
import {WorkerService} from '../worker/WorkerService';
|
||||
|
||||
let jsConnectionManager: JetStreamConnectionManager | null = null;
|
||||
|
||||
function unsupportedDatabaseBackend(backend: never): never {
|
||||
throw new Error(`Unsupported database backend during shutdown: ${String(backend)}`);
|
||||
}
|
||||
|
||||
export function createInitializer(config: APIConfig, logger: ILogger): () => Promise<void> {
|
||||
return async (): Promise<void> => {
|
||||
try {
|
||||
@@ -131,7 +136,7 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
|
||||
try {
|
||||
const kvDeletionQueue = getKVAccountDeletionQueue();
|
||||
if (await kvDeletionQueue.needsRebuild()) {
|
||||
logger.warn('KV deletion queue needs rebuild, rebuilding...');
|
||||
logger.info('KV deletion queue needs rebuild, rebuilding...');
|
||||
await kvDeletionQueue.rebuildState();
|
||||
} else {
|
||||
logger.info('KV deletion queue state is healthy');
|
||||
@@ -204,13 +209,13 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
|
||||
logger.info('API service initialization complete');
|
||||
} catch (error) {
|
||||
logger.error({error}, 'API service initialization failed');
|
||||
await createShutdown(logger)();
|
||||
await createShutdown(config, logger)();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createShutdown(logger: ILogger): () => Promise<void> {
|
||||
export function createShutdown(config: APIConfig, logger: ILogger): () => Promise<void> {
|
||||
return async (): Promise<void> => {
|
||||
logger.info('Shutting down API service...');
|
||||
if (jsConnectionManager) {
|
||||
@@ -258,18 +263,26 @@ export function createShutdown(logger: ILogger): () => Promise<void> {
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Error shutting down report service');
|
||||
}
|
||||
try {
|
||||
setDatabaseQueryExecutor(null);
|
||||
await shutdownPostgres();
|
||||
logger.info('Postgres client shut down');
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Error shutting down Postgres client');
|
||||
}
|
||||
try {
|
||||
await shutdownCassandra();
|
||||
logger.info('Cassandra client shut down');
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Error shutting down Cassandra client');
|
||||
setDatabaseQueryExecutor(null);
|
||||
switch (config.database.backend) {
|
||||
case 'postgres':
|
||||
try {
|
||||
await shutdownPostgres();
|
||||
logger.info('Postgres client shut down');
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Error shutting down Postgres client');
|
||||
}
|
||||
break;
|
||||
case 'cassandra':
|
||||
try {
|
||||
await shutdownCassandra();
|
||||
logger.info('Cassandra client shut down');
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Error shutting down Cassandra client');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
unsupportedDatabaseBackend(config.database.backend);
|
||||
}
|
||||
logger.info('API service shutdown complete');
|
||||
};
|
||||
|
||||
@@ -66,7 +66,9 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
TestHarnessController(routes);
|
||||
}
|
||||
UserController(routes);
|
||||
registerInboundSmsWebhook(routes);
|
||||
if (config.sms.enabled) {
|
||||
registerInboundSmsWebhook(routes);
|
||||
}
|
||||
WebhookController(routes);
|
||||
OAuth2Controller(routes);
|
||||
OAuth2ApplicationsController(routes);
|
||||
|
||||
@@ -204,6 +204,7 @@ export interface APIConfig {
|
||||
apiSecret?: string;
|
||||
webhookUrl?: string;
|
||||
url?: string;
|
||||
internalUrl?: string;
|
||||
defaultRegion?: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -19,6 +19,8 @@ interface PageState {
|
||||
|
||||
const VALUE_SEPARATOR = '\u001f';
|
||||
const ENCODED_TYPE_KEY = '__fluxer_type';
|
||||
const POSTGRES_KV_SCHEMA_LOCK_NAMESPACE = 0x46584b56;
|
||||
const POSTGRES_KV_SCHEMA_LOCK_TIMEOUT = '120s';
|
||||
|
||||
function normalizeCql(cql: string): string {
|
||||
return cql.replace(/\s+/g, ' ').trim();
|
||||
@@ -354,8 +356,13 @@ function parseEqWhere(whereSql: string, cql: string): ReadonlyArray<EqWhereExpr>
|
||||
}
|
||||
|
||||
export async function ensurePostgresKvSchema(client: IPostgresClient): Promise<void> {
|
||||
const table = quoteIdentifier(client.kvTable());
|
||||
await client.query(`
|
||||
const kvTable = client.kvTable();
|
||||
const table = quoteIdentifier(kvTable);
|
||||
await client.transaction(async (db) => {
|
||||
await db.query("SELECT set_config('statement_timeout', $1, true)", [POSTGRES_KV_SCHEMA_LOCK_TIMEOUT]);
|
||||
await db.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [POSTGRES_KV_SCHEMA_LOCK_NAMESPACE, kvTable]);
|
||||
await db.query("SELECT set_config('statement_timeout', '0', true)");
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS ${table} (
|
||||
table_name text NOT NULL,
|
||||
partition_key text NOT NULL,
|
||||
@@ -365,28 +372,29 @@ CREATE TABLE IF NOT EXISTS ${table} (
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (table_name, row_key)
|
||||
)`);
|
||||
await client.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_partition_row_idx`)} ON ${table} (table_name, partition_key, row_key)`,
|
||||
);
|
||||
await client.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_row_key_c_idx`)} ON ${table} (table_name, row_key COLLATE "C")`,
|
||||
);
|
||||
await client.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_expires_idx`)} ON ${table} (expires_at) WHERE expires_at IS NOT NULL`,
|
||||
);
|
||||
await client.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_messages_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'messages'`,
|
||||
);
|
||||
await client.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_message_reactions_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'message_reactions'`,
|
||||
);
|
||||
await client.query(`
|
||||
await db.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_partition_row_idx`)} ON ${table} (table_name, partition_key, row_key)`,
|
||||
);
|
||||
await db.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_row_key_c_idx`)} ON ${table} (table_name, row_key COLLATE "C")`,
|
||||
);
|
||||
await db.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_expires_idx`)} ON ${table} (expires_at) WHERE expires_at IS NOT NULL`,
|
||||
);
|
||||
await db.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_messages_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'messages'`,
|
||||
);
|
||||
await db.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_message_reactions_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'message_reactions'`,
|
||||
);
|
||||
await db.query(`
|
||||
UPDATE ${table}
|
||||
SET partition_key = split_part(row_key, chr(31), 1) || chr(31) || split_part(row_key, chr(31), 2)
|
||||
WHERE table_name = 'messages'
|
||||
AND partition_key = row_key
|
||||
AND split_part(row_key, chr(31), 3) <> ''`);
|
||||
await client.query(`DROP INDEX IF EXISTS ${quoteIdentifier(`${client.kvTable()}_partition_idx`)}`);
|
||||
await db.query(`DROP INDEX IF EXISTS ${quoteIdentifier(`${kvTable}_partition_idx`)}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function pruneExpiredPostgresKvRows(client: IPostgresClient, batchSize = 5000): Promise<number> {
|
||||
|
||||
@@ -60,7 +60,7 @@ export class KVAccountDeletionQueueService {
|
||||
}
|
||||
|
||||
async rebuildState(): Promise<void> {
|
||||
Logger.info('Starting deletion queue rebuild from Cassandra');
|
||||
Logger.info('Starting deletion queue rebuild from primary database');
|
||||
try {
|
||||
await this.kvClient.del(QUEUE_KEY);
|
||||
await this.kvClient.del(STATE_VERSION_KEY);
|
||||
|
||||
@@ -109,6 +109,18 @@ function createRoomServiceClient(endpoint: string, apiKey: string, apiSecret: st
|
||||
return client;
|
||||
}
|
||||
|
||||
function resolveRoomServiceEndpoint(server: VoiceServerRecord): string {
|
||||
const defaultRegion = Config.voice.defaultRegion;
|
||||
const internalUrl = Config.voice.internalUrl;
|
||||
if (!defaultRegion || !internalUrl) {
|
||||
return server.endpoint;
|
||||
}
|
||||
if (server.regionId !== defaultRegion.id || server.serverId !== `${defaultRegion.id}-server-1`) {
|
||||
return server.endpoint;
|
||||
}
|
||||
return internalUrl;
|
||||
}
|
||||
|
||||
export class LiveKitService extends ILiveKitService {
|
||||
private serverClients: Map<string, Map<string, ServerClientConfig>> = new Map();
|
||||
private topology: VoiceTopology;
|
||||
@@ -453,12 +465,13 @@ export class LiveKitService extends ILiveKitService {
|
||||
const servers = this.topology.getServersForRegion(region.id);
|
||||
const serverMap: Map<string, ServerClientConfig> = new Map();
|
||||
for (const server of servers) {
|
||||
const roomServiceEndpoint = resolveRoomServiceEndpoint(server);
|
||||
serverMap.set(server.serverId, {
|
||||
endpoint: server.endpoint,
|
||||
apiKey: server.apiKey,
|
||||
apiSecret: server.apiSecret,
|
||||
isActive: server.isActive,
|
||||
roomServiceClient: createRoomServiceClient(server.endpoint, server.apiKey, server.apiSecret),
|
||||
roomServiceClient: createRoomServiceClient(roomServiceEndpoint, server.apiKey, server.apiSecret),
|
||||
});
|
||||
}
|
||||
newMap.set(region.id, serverMap);
|
||||
|
||||
@@ -284,7 +284,7 @@ export function setInjectedAccountPolicyEvaluator(evaluator: IAccountPolicyEvalu
|
||||
function getRegistrationRiskEvaluator(): IRegistrationRiskEvaluator {
|
||||
if (_registrationRiskEvaluator) return _registrationRiskEvaluator;
|
||||
if (!Config.risk.enabled) {
|
||||
Logger.warn(
|
||||
Logger.info(
|
||||
{},
|
||||
'[ServiceMiddleware] integrations.risk_integration.enabled is false — account risk scoring is disabled',
|
||||
);
|
||||
|
||||
@@ -148,7 +148,7 @@ pub async fn proxy_assets(
|
||||
response
|
||||
}
|
||||
|
||||
async fn serve_local_asset(
|
||||
pub(super) async fn serve_local_asset(
|
||||
static_dir: &str,
|
||||
relative_path: &str,
|
||||
request_headers: &HeaderMap,
|
||||
|
||||
@@ -19,6 +19,7 @@ use axum::{
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::assets_proxy::serve_local_asset;
|
||||
use super::spa_static::{CORS_ALLOW_ANY_VALUE, guess_mime, is_font_mime};
|
||||
|
||||
const ACCEPT_CH_VALUE: &str = "DPR, Sec-CH-DPR, Sec-CH-Width, Save-Data, ECT, Downlink";
|
||||
@@ -35,6 +36,14 @@ pub async fn spa_catch_all(
|
||||
if let Some(cache_control) = static_root_file_cache_control(request_path) {
|
||||
return serve_static_file(&state.config.static_dir, request_path, cache_control).await;
|
||||
}
|
||||
if is_static_asset_path(request_path) {
|
||||
return serve_local_asset(
|
||||
&state.config.static_dir,
|
||||
request_path.trim_start_matches('/'),
|
||||
&headers,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
serve_spa_index(&state, &headers, request_path).await
|
||||
}
|
||||
@@ -42,6 +51,16 @@ pub async fn spa_catch_all(
|
||||
const CRAWL_CONTROL_CACHE_CONTROL: &str = "public, max-age=300, must-revalidate";
|
||||
|
||||
const STATIC_ROOT_FILES: &[(&str, &str)] = &[("/robots.txt", CRAWL_CONTROL_CACHE_CONTROL)];
|
||||
const STATIC_ASSET_PREFIXES: &[&str] = &[
|
||||
"/avatars/",
|
||||
"/badges/",
|
||||
"/desktop/",
|
||||
"/embeds/",
|
||||
"/emoji/",
|
||||
"/libs/",
|
||||
"/marketing/",
|
||||
"/web/",
|
||||
];
|
||||
|
||||
fn static_root_file_cache_control(request_path: &str) -> Option<&'static str> {
|
||||
STATIC_ROOT_FILES
|
||||
@@ -50,6 +69,12 @@ fn static_root_file_cache_control(request_path: &str) -> Option<&'static str> {
|
||||
.map(|(_, cache_control)| *cache_control)
|
||||
}
|
||||
|
||||
fn is_static_asset_path(request_path: &str) -> bool {
|
||||
STATIC_ASSET_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| request_path.starts_with(prefix))
|
||||
}
|
||||
|
||||
async fn serve_static_file(
|
||||
static_dir: &str,
|
||||
request_path: &str,
|
||||
|
||||
+28
-22
@@ -3,6 +3,7 @@ set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV="$HERE/.venv"
|
||||
REQUIREMENTS_STAMP="$VENV/.requirements.sha256"
|
||||
ADDR="${ZENSICAL_DEV_ADDR:-0.0.0.0:8000}"
|
||||
LOG="${ZENSICAL_LOG:-/tmp/zensical-serve.log}"
|
||||
PORT="${ADDR##*:}"
|
||||
@@ -14,29 +15,34 @@ ensure_env() {
|
||||
python3 -m venv "$VENV"
|
||||
"$VENV/bin/python" -m pip install --quiet --upgrade pip
|
||||
fi
|
||||
"$VENV/bin/python" -m pip install --quiet --require-virtualenv -r "$HERE/requirements.txt"
|
||||
python_fingerprint="$(python3 -c 'import platform, sys; print(f"{sys.implementation.cache_tag}:{platform.machine()}")')"
|
||||
requirements_hash="$(printf '%s\0%s\n' "$python_fingerprint" "$(sha256sum "$HERE/requirements.txt" | cut -d' ' -f1)" | sha256sum | cut -d' ' -f1)"
|
||||
if [ ! -f "$REQUIREMENTS_STAMP" ] || [ "$(cat "$REQUIREMENTS_STAMP")" != "$requirements_hash" ]; then
|
||||
"$VENV/bin/python" -m pip install --quiet --require-virtualenv -r "$HERE/requirements.txt"
|
||||
printf '%s\n' "$requirements_hash" >"$REQUIREMENTS_STAMP"
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-serve}" in
|
||||
--bootstrap)
|
||||
ensure_env
|
||||
;;
|
||||
--daemon)
|
||||
ensure_env
|
||||
if curl -sf -o /dev/null "http://127.0.0.1:${PORT}/" 2>/dev/null; then
|
||||
echo "zensical already serving on ${ADDR}"
|
||||
exit 0
|
||||
fi
|
||||
setsid "$VENV/bin/zensical" serve -a "$ADDR" >"$LOG" 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
echo "zensical serving on ${ADDR} (logs: ${LOG})"
|
||||
;;
|
||||
serve)
|
||||
ensure_env
|
||||
exec "$VENV/bin/zensical" serve -a "$ADDR"
|
||||
;;
|
||||
*)
|
||||
ensure_env
|
||||
exec "$VENV/bin/zensical" "$@"
|
||||
;;
|
||||
--bootstrap)
|
||||
ensure_env
|
||||
;;
|
||||
--daemon)
|
||||
ensure_env
|
||||
if curl -sf -o /dev/null "http://127.0.0.1:${PORT}/" 2>/dev/null; then
|
||||
echo "zensical already serving on ${ADDR}"
|
||||
exit 0
|
||||
fi
|
||||
setsid "$VENV/bin/zensical" serve -a "$ADDR" >"$LOG" 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
echo "zensical serving on ${ADDR} (logs: ${LOG})"
|
||||
;;
|
||||
serve)
|
||||
ensure_env
|
||||
exec "$VENV/bin/zensical" serve -a "$ADDR"
|
||||
;;
|
||||
*)
|
||||
ensure_env
|
||||
exec "$VENV/bin/zensical" "$@"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -12,22 +12,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
make \
|
||||
cmake \
|
||||
gcc \
|
||||
g++ \
|
||||
libc6-dev \
|
||||
libssl-dev \
|
||||
libuv1-dev \
|
||||
zlib1g-dev \
|
||||
pkg-config \
|
||||
gettext-base \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -fsSL https://github.com/erlang/rebar3/releases/download/3.24.0/rebar3 -o /usr/local/bin/rebar3 && \
|
||||
RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://github.com/erlang/rebar3/releases/download/3.24.0/rebar3 -o /usr/local/bin/rebar3 && \
|
||||
chmod +x /usr/local/bin/rebar3
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | \
|
||||
RUN curl --retry 5 --retry-delay 2 --retry-all-errors --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain "${RUST_TOOLCHAIN}"
|
||||
|
||||
COPY . .
|
||||
@@ -47,8 +42,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
libstdc++6 \
|
||||
libuv1t64 \
|
||||
zlib1g \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /usr/src/app/fluxer_gateway/_build/prod/rel/fluxer_gateway .
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
{jose, "1.11.10"},
|
||||
{ezstd, "1.1.0"},
|
||||
{enats, "1.2.0"},
|
||||
{erlcass, "4.1.3"},
|
||||
{eqwalizer_support,
|
||||
{git_subdir, "https://github.com/whatsapp/eqwalizer.git",
|
||||
{ref, "c57aa9e3a05553d1ba66ea647a2177eaa469211b"}, "eqwalizer_support"}}
|
||||
@@ -32,7 +31,6 @@
|
||||
{relx, [
|
||||
{release, {fluxer_gateway, "0.0.0"}, [
|
||||
fluxer_gateway,
|
||||
{erlcass, load},
|
||||
enats,
|
||||
sasl
|
||||
]},
|
||||
@@ -90,7 +88,7 @@
|
||||
]},
|
||||
{plt_apps, all_deps},
|
||||
{plt_extra_apps, [
|
||||
crypto, enats, erlcass, inets, jose, public_key, ranch, ssl
|
||||
crypto, enats, inets, jose, public_key, ranch, ssl
|
||||
]},
|
||||
{warnings_file, "dialyzer.ignore-warnings"}
|
||||
]}.
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
{ref,"c57aa9e3a05553d1ba66ea647a2177eaa469211b"},
|
||||
"eqwalizer_support"},
|
||||
0},
|
||||
{<<"erlcass">>,{pkg,<<"erlcass">>,<<"4.1.3">>},0},
|
||||
{<<"ezstd">>,{pkg,<<"ezstd">>,<<"1.1.0">>},0},
|
||||
{<<"jose">>,{pkg,<<"jose">>,<<"1.11.10">>},0},
|
||||
{<<"opentelemetry_api">>,{pkg,<<"opentelemetry_api">>,<<"1.4.0">>},1},
|
||||
@@ -21,7 +20,6 @@
|
||||
{<<"cowlib">>, <<"54592074EBBBB92EE4746C8A8846E5605052F29309D3A873468D76CDF932076F">>},
|
||||
{<<"enats">>, <<"D7459C804013CAFA4AF880B18D446C48890D28D372D62AD66C76187E5779248D">>},
|
||||
{<<"enats_msg">>, <<"50631124F37D88BE76A91A5B96A6565C5981EBF917CD819F0175CA658A966F43">>},
|
||||
{<<"erlcass">>, <<"17AC8F39B0A4703B12A8994663920993E191757831E125CF2C7EFB267DB66E7D">>},
|
||||
{<<"ezstd">>, <<"D3B483D6ACFADFB65DBA4015371E6D54526DBF3D9EF0941B5ADD8BF5890731F4">>},
|
||||
{<<"jose">>, <<"A903F5227417BD2A08C8A00A0CBCC458118BE84480955E8D251297A425723F83">>},
|
||||
{<<"opentelemetry_api">>, <<"63CA1742F92F00059298F478048DFB826F4B20D49534493D6919A0DB39B6DB04">>},
|
||||
@@ -32,7 +30,6 @@
|
||||
{<<"cowlib">>, <<"7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51">>},
|
||||
{<<"enats">>, <<"20DEB3CB1D3E960194DF8B136C40D2DB085B485BBA5E493B340AB5F9FD2BED22">>},
|
||||
{<<"enats_msg">>, <<"C4F2139E5144FABC99AFE01B8B016AD9DA278CDDC60857AC5D5AFB0AD1283534">>},
|
||||
{<<"erlcass">>, <<"37237C12ABE5745E0F900180D0ADB129634619BD6B2F57995BA020E63DDC6BEC">>},
|
||||
{<<"ezstd">>, <<"28CFA0ED6CC3922095AD5BA0F23392A1664273358B17184BAA909868361184E7">>},
|
||||
{<<"jose">>, <<"0D6CD36FF8BA174DB29148FC112B5842186B68A90CE9FC2B3EC3AFE76593E614">>},
|
||||
{<<"opentelemetry_api">>, <<"3DFBBFAA2C2ED3121C5C483162836C4F9027DEF469C41578AF5EF32589FCFC58">>},
|
||||
|
||||
@@ -59,7 +59,7 @@ env_services_config() ->
|
||||
|
||||
-spec env_gateway_config() -> map().
|
||||
env_gateway_config() ->
|
||||
maps:merge(env_gateway_base_config(), env_gateway_hotpatch_config()).
|
||||
env_gateway_base_config().
|
||||
|
||||
-spec env_gateway_base_config() -> map().
|
||||
env_gateway_base_config() ->
|
||||
@@ -107,36 +107,6 @@ env_gateway_base_config() ->
|
||||
)
|
||||
}.
|
||||
|
||||
-spec env_gateway_hotpatch_config() -> map().
|
||||
env_gateway_hotpatch_config() ->
|
||||
#{
|
||||
<<"hotpatch_enabled">> => env_bool("FLUXER_GATEWAY_HOTPATCH_ENABLED", false),
|
||||
<<"hotpatch_cassandra_hosts">> => env_optional_binary(
|
||||
"FLUXER_GATEWAY_HOTPATCH_CASSANDRA_HOSTS"
|
||||
),
|
||||
<<"hotpatch_cassandra_port">> => env_int(
|
||||
"FLUXER_GATEWAY_HOTPATCH_CASSANDRA_PORT", 9042
|
||||
),
|
||||
<<"hotpatch_cassandra_keyspace">> => env_binary(
|
||||
"FLUXER_GATEWAY_HOTPATCH_CASSANDRA_KEYSPACE", <<"fluxer">>
|
||||
),
|
||||
<<"hotpatch_cassandra_username">> => env_optional_binary(
|
||||
"FLUXER_GATEWAY_HOTPATCH_CASSANDRA_USERNAME"
|
||||
),
|
||||
<<"hotpatch_cassandra_password">> => env_optional_binary(
|
||||
"FLUXER_GATEWAY_HOTPATCH_CASSANDRA_PASSWORD"
|
||||
),
|
||||
<<"hotpatch_public_keys">> => env_optional_binary(
|
||||
"FLUXER_GATEWAY_HOTPATCH_PUBLIC_KEYS"
|
||||
),
|
||||
<<"hotpatch_poll_interval_ms">> => env_int(
|
||||
"FLUXER_GATEWAY_HOTPATCH_POLL_INTERVAL_MS", 5000
|
||||
),
|
||||
<<"hotpatch_startup_sync_timeout_ms">> => env_int(
|
||||
"FLUXER_GATEWAY_HOTPATCH_STARTUP_SYNC_TIMEOUT_MS", 30000
|
||||
)
|
||||
}.
|
||||
|
||||
-spec env_nats_config() -> map().
|
||||
env_nats_config() ->
|
||||
#{
|
||||
@@ -295,20 +265,6 @@ build_http_config(Service) ->
|
||||
),
|
||||
gateway_http_recovery_timeout_ms =>
|
||||
get_int(Service, <<"gateway_http_recovery_timeout_ms">>, 15000),
|
||||
hotpatch_enabled => get_bool(Service, <<"hotpatch_enabled">>, false),
|
||||
hotpatch_cassandra_hosts =>
|
||||
optional_string(get_optional_binary(Service, <<"hotpatch_cassandra_hosts">>)),
|
||||
hotpatch_cassandra_port => get_int(Service, <<"hotpatch_cassandra_port">>, 9042),
|
||||
hotpatch_cassandra_keyspace =>
|
||||
get_binary(Service, <<"hotpatch_cassandra_keyspace">>, <<"fluxer">>),
|
||||
hotpatch_cassandra_username =>
|
||||
get_optional_binary(Service, <<"hotpatch_cassandra_username">>),
|
||||
hotpatch_cassandra_password =>
|
||||
get_optional_binary(Service, <<"hotpatch_cassandra_password">>),
|
||||
hotpatch_public_keys => get_optional_binary(Service, <<"hotpatch_public_keys">>),
|
||||
hotpatch_poll_interval_ms => get_int(Service, <<"hotpatch_poll_interval_ms">>, 5000),
|
||||
hotpatch_startup_sync_timeout_ms =>
|
||||
get_int(Service, <<"hotpatch_startup_sync_timeout_ms">>, 30000),
|
||||
gateway_http_cleanup_interval_ms =>
|
||||
get_int(Service, <<"gateway_http_cleanup_interval_ms">>, 30000),
|
||||
gateway_http_cleanup_max_age_ms =>
|
||||
|
||||
@@ -38,8 +38,7 @@ common_children() ->
|
||||
child_spec(gateway_nats_pool, gateway_nats_pool),
|
||||
child_spec(gateway_event_pause, gateway_event_pause),
|
||||
child_spec(gateway_concurrency, gateway_concurrency),
|
||||
child_spec(gateway_rollout_config, gateway_rollout_config),
|
||||
child_spec(gateway_hotpatch_reconciler, gateway_hotpatch_reconciler)
|
||||
child_spec(gateway_rollout_config, gateway_rollout_config)
|
||||
] ++ cluster_children() ++
|
||||
[
|
||||
child_spec(gateway_dispatch_relay, gateway_dispatch_relay),
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(gateway_hotpatch_bundle).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([
|
||||
compress_term/1,
|
||||
decompress_term/1,
|
||||
bundle_hash/1,
|
||||
signing_payload/1,
|
||||
sign/2,
|
||||
verify_signature/4,
|
||||
parse_public_keys/1,
|
||||
decode_signed_event/2
|
||||
]).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-endif.
|
||||
|
||||
-define(DOMAIN, <<"fluxer-gateway-hotpatch-v1">>).
|
||||
-define(ZSTD_LEVEL, 3).
|
||||
|
||||
-spec compress_term(term()) -> {ok, binary()} | {error, term()}.
|
||||
compress_term(Term) ->
|
||||
compress_binary(term_to_binary(Term, [deterministic])).
|
||||
|
||||
-spec decompress_term(binary()) -> {ok, term()} | {error, term()}.
|
||||
decompress_term(Compressed) when is_binary(Compressed) ->
|
||||
case decompress_binary(Compressed) of
|
||||
{ok, Binary} -> decode_term(Binary);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec decode_term(binary()) -> {ok, term()} | {error, term()}.
|
||||
decode_term(Binary) ->
|
||||
try
|
||||
{ok, binary_to_term(Binary, [safe])}
|
||||
catch
|
||||
Class:Reason -> {error, {invalid_term, Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec bundle_hash(binary()) -> binary().
|
||||
bundle_hash(CompressedBundle) when is_binary(CompressedBundle) ->
|
||||
crypto:hash(sha256, CompressedBundle).
|
||||
|
||||
-spec signing_payload(binary()) -> binary().
|
||||
signing_payload(CompressedBundle) when is_binary(CompressedBundle) ->
|
||||
<<?DOMAIN/binary, 0, CompressedBundle/binary>>.
|
||||
|
||||
-spec sign(binary(), binary()) -> {ok, binary()} | {error, term()}.
|
||||
sign(CompressedBundle, PrivateKey) when is_binary(CompressedBundle), is_binary(PrivateKey) ->
|
||||
try
|
||||
{ok, crypto:sign(eddsa, none, signing_payload(CompressedBundle), [PrivateKey, ed25519])}
|
||||
catch
|
||||
Class:Reason -> {error, {sign_failed, Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec verify_signature(binary(), binary(), binary(), #{binary() => binary()}) ->
|
||||
ok | {error, term()}.
|
||||
verify_signature(CompressedBundle, SignerKeyId, Signature, PublicKeys) when
|
||||
is_binary(CompressedBundle),
|
||||
is_binary(SignerKeyId),
|
||||
is_binary(Signature),
|
||||
is_map(PublicKeys)
|
||||
->
|
||||
case maps:get(SignerKeyId, PublicKeys, undefined) of
|
||||
undefined ->
|
||||
{error, {unknown_signer, SignerKeyId}};
|
||||
PublicKey ->
|
||||
verify_with_key(CompressedBundle, Signature, PublicKey)
|
||||
end.
|
||||
|
||||
-spec parse_public_keys(term()) ->
|
||||
{ok, #{binary() => binary()}} | {error, term()}.
|
||||
parse_public_keys(undefined) ->
|
||||
{ok, #{}};
|
||||
parse_public_keys(Value) when is_list(Value) ->
|
||||
parse_public_keys(type_conv:ensure_binary(Value));
|
||||
parse_public_keys(Value) when is_binary(Value) ->
|
||||
Tokens = [
|
||||
string:trim(Token)
|
||||
|| Token <- binary:split(Value, [<<",">>, <<"\n">>, <<";">>], [global]),
|
||||
string:trim(Token) =/= <<>>
|
||||
],
|
||||
parse_public_key_tokens(Tokens, #{});
|
||||
parse_public_keys(Value) ->
|
||||
{error, {invalid_public_keys_config, Value}}.
|
||||
|
||||
-spec decode_signed_event(map(), #{binary() => binary()}) -> {ok, map()} | {error, term()}.
|
||||
decode_signed_event(Event, PublicKeys) when is_map(Event), is_map(PublicKeys) ->
|
||||
Bundle = event_value(bundle, Event),
|
||||
SignerKeyId = event_value(signer_key_id, Event),
|
||||
Signature = event_value(signature, Event),
|
||||
BundleHash = event_value(bundle_sha256, Event),
|
||||
decode_event_payload(Bundle, SignerKeyId, Signature, BundleHash, PublicKeys).
|
||||
|
||||
-spec decode_event_payload(term(), term(), term(), term(), #{binary() => binary()}) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
decode_event_payload(Bundle, SignerKeyId, Signature, BundleHash, PublicKeys) when
|
||||
is_binary(Bundle), is_binary(SignerKeyId), is_binary(Signature), is_binary(BundleHash)
|
||||
->
|
||||
case validate_event_hash(Bundle, BundleHash) of
|
||||
ok -> decode_verified_event(Bundle, SignerKeyId, Signature, PublicKeys);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end;
|
||||
decode_event_payload(_Bundle, _SignerKeyId, _Signature, _BundleHash, _PublicKeys) ->
|
||||
{error, invalid_event_payload}.
|
||||
|
||||
-spec decode_verified_event(binary(), binary(), binary(), #{binary() => binary()}) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
decode_verified_event(Bundle, SignerKeyId, Signature, PublicKeys) ->
|
||||
case verify_signature(Bundle, SignerKeyId, Signature, PublicKeys) of
|
||||
ok -> decompress_bundle_map(Bundle);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec decompress_bundle_map(binary()) -> {ok, map()} | {error, term()}.
|
||||
decompress_bundle_map(Bundle) ->
|
||||
case decompress_term(Bundle) of
|
||||
{ok, Map} when is_map(Map) -> {ok, Map};
|
||||
{ok, Other} -> {error, {invalid_bundle_term, Other}};
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec compress_binary(binary()) -> {ok, binary()} | {error, term()}.
|
||||
compress_binary(Binary) ->
|
||||
try erlang:apply(ezstd, compress, [Binary, ?ZSTD_LEVEL]) of
|
||||
Compressed when is_binary(Compressed) -> {ok, Compressed};
|
||||
{error, Reason} -> {error, {compress_failed, Reason}};
|
||||
Other -> {error, {compress_failed, Other}}
|
||||
catch
|
||||
Class:Reason -> {error, {compress_failed, Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec decompress_binary(binary()) -> {ok, binary()} | {error, term()}.
|
||||
decompress_binary(Binary) ->
|
||||
try erlang:apply(ezstd, decompress, [Binary]) of
|
||||
Decompressed when is_binary(Decompressed) -> {ok, Decompressed};
|
||||
Decompressed when is_list(Decompressed) -> {ok, iolist_to_binary(Decompressed)};
|
||||
{error, Reason} -> {error, {decompress_failed, Reason}};
|
||||
Other -> {error, {decompress_failed, Other}}
|
||||
catch
|
||||
Class:Reason -> {error, {decompress_failed, Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec verify_with_key(binary(), binary(), binary()) -> ok | {error, term()}.
|
||||
verify_with_key(CompressedBundle, Signature, PublicKey) when byte_size(PublicKey) =:= 32 ->
|
||||
verify_with_valid_key(CompressedBundle, Signature, PublicKey);
|
||||
verify_with_key(_CompressedBundle, _Signature, PublicKey) ->
|
||||
{error, {invalid_public_key_size, byte_size(PublicKey)}}.
|
||||
|
||||
-spec verify_with_valid_key(binary(), binary(), binary()) -> ok | {error, term()}.
|
||||
verify_with_valid_key(CompressedBundle, Signature, PublicKey) ->
|
||||
try
|
||||
crypto:verify(eddsa, none, signing_payload(CompressedBundle), Signature, [
|
||||
PublicKey, ed25519
|
||||
])
|
||||
of
|
||||
true -> ok;
|
||||
false -> {error, invalid_signature}
|
||||
catch
|
||||
Class:Reason -> {error, {verify_failed, Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec parse_public_key_tokens([binary()], #{binary() => binary()}) ->
|
||||
{ok, #{binary() => binary()}} | {error, term()}.
|
||||
parse_public_key_tokens([], Acc) ->
|
||||
{ok, Acc};
|
||||
parse_public_key_tokens([Token | Rest], Acc) ->
|
||||
case parse_public_key_token(Token) of
|
||||
{ok, KeyId, PublicKey} -> parse_public_key_tokens(Rest, Acc#{KeyId => PublicKey});
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec parse_public_key_token(binary()) -> {ok, binary(), binary()} | {error, term()}.
|
||||
parse_public_key_token(Token) ->
|
||||
case split_key_token(Token) of
|
||||
{ok, KeyId, Encoded} -> parse_public_key_material(KeyId, Encoded);
|
||||
error -> {error, {invalid_public_key_token, Token}}
|
||||
end.
|
||||
|
||||
-spec parse_public_key_material(binary(), binary()) ->
|
||||
{ok, binary(), binary()} | {error, term()}.
|
||||
parse_public_key_material(KeyId, Encoded) ->
|
||||
case decode_key_material(Encoded) of
|
||||
{ok, PublicKey} when byte_size(PublicKey) =:= 32 -> {ok, KeyId, PublicKey};
|
||||
{ok, PublicKey} -> {error, {invalid_public_key_size, KeyId, byte_size(PublicKey)}};
|
||||
{error, Reason} -> {error, {invalid_public_key, KeyId, Reason}}
|
||||
end.
|
||||
|
||||
-spec split_key_token(binary()) -> {ok, binary(), binary()} | error.
|
||||
split_key_token(Token) ->
|
||||
case binary:split(Token, <<":">>) of
|
||||
[KeyId, Encoded] -> {ok, string:trim(KeyId), string:trim(Encoded)};
|
||||
_ -> split_key_token_equals(Token)
|
||||
end.
|
||||
|
||||
-spec split_key_token_equals(binary()) -> {ok, binary(), binary()} | error.
|
||||
split_key_token_equals(Token) ->
|
||||
case binary:split(Token, <<"=">>) of
|
||||
[KeyId, Encoded] -> {ok, string:trim(KeyId), string:trim(Encoded)};
|
||||
_ -> error
|
||||
end.
|
||||
|
||||
-spec decode_key_material(binary()) -> {ok, binary()} | {error, term()}.
|
||||
decode_key_material(Encoded) ->
|
||||
case try_base64(Encoded) of
|
||||
{ok, Decoded} -> {ok, Decoded};
|
||||
{error, _} -> try_base64url(Encoded)
|
||||
end.
|
||||
|
||||
-spec try_base64(binary()) -> {ok, binary()} | {error, term()}.
|
||||
try_base64(Encoded) ->
|
||||
try
|
||||
{ok, base64:decode(Encoded)}
|
||||
catch
|
||||
Class:Reason -> {error, {Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec try_base64url(binary()) -> {ok, binary()} | {error, term()}.
|
||||
try_base64url(Encoded) ->
|
||||
try
|
||||
{ok, base64url:decode(Encoded)}
|
||||
catch
|
||||
Class:Reason -> {error, {Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec validate_event_hash(binary(), binary()) -> ok | {error, term()}.
|
||||
validate_event_hash(Bundle, BundleHash) ->
|
||||
case bundle_hash(Bundle) of
|
||||
BundleHash -> ok;
|
||||
Other -> {error, {bundle_hash_mismatch, Other, BundleHash}}
|
||||
end.
|
||||
|
||||
-spec event_value(atom(), map()) -> term().
|
||||
event_value(Key, Event) ->
|
||||
maps:get(Key, Event, maps:get(atom_to_binary(Key, utf8), Event, undefined)).
|
||||
|
||||
-ifdef(TEST).
|
||||
|
||||
compress_decompress_roundtrip_test() ->
|
||||
Term = #{
|
||||
version => 1,
|
||||
build_sha => <<"abc123">>,
|
||||
modules => [#{module => <<"session_lifecycle">>, expected_current_md5 => <<0:128>>}]
|
||||
},
|
||||
{ok, Compressed} = compress_term(Term),
|
||||
?assert(is_binary(Compressed)),
|
||||
?assertEqual({ok, Term}, decompress_term(Compressed)).
|
||||
|
||||
sign_and_verify_roundtrip_test() ->
|
||||
{PublicKey, PrivateKey} = ed25519_keypair(),
|
||||
{ok, Compressed} = compress_term(#{version => 1}),
|
||||
{ok, Signature} = sign(Compressed, PrivateKey),
|
||||
Keys = #{<<"ops">> => PublicKey},
|
||||
?assertEqual(ok, verify_signature(Compressed, <<"ops">>, Signature, Keys)),
|
||||
?assertEqual(
|
||||
{error, invalid_signature},
|
||||
verify_signature(<<Compressed/binary, 0>>, <<"ops">>, Signature, Keys)
|
||||
).
|
||||
|
||||
decode_signed_event_rejects_hash_mismatch_test() ->
|
||||
{PublicKey, PrivateKey} = ed25519_keypair(),
|
||||
{ok, Compressed} = compress_term(#{version => 1}),
|
||||
{ok, Signature} = sign(Compressed, PrivateKey),
|
||||
Event = #{
|
||||
signer_key_id => <<"ops">>,
|
||||
signature => Signature,
|
||||
bundle_sha256 => <<0:256>>,
|
||||
bundle => Compressed
|
||||
},
|
||||
?assertMatch(
|
||||
{error, {bundle_hash_mismatch, _, _}},
|
||||
decode_signed_event(Event, #{<<"ops">> => PublicKey})
|
||||
).
|
||||
|
||||
parse_public_keys_test() ->
|
||||
PublicKey = <<1:256>>,
|
||||
Encoded = base64:encode(PublicKey),
|
||||
?assertEqual(
|
||||
{ok, #{<<"ops">> => PublicKey}}, parse_public_keys(<<"ops:", Encoded/binary>>)
|
||||
).
|
||||
|
||||
ed25519_keypair() ->
|
||||
{PublicKey, PrivateKey} = crypto:generate_key(eddsa, ed25519),
|
||||
{require_binary(PublicKey), require_binary(PrivateKey)}.
|
||||
|
||||
require_binary(Value) when is_binary(Value) ->
|
||||
Value.
|
||||
|
||||
-endif.
|
||||
@@ -1,459 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(gateway_hotpatch_cli).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([main/1, build_bundle/2, sign_bundle/3]).
|
||||
|
||||
-spec main([string()]) -> no_return().
|
||||
main(Args) ->
|
||||
halt(run_main(Args)).
|
||||
|
||||
-spec run_main([string()]) -> non_neg_integer().
|
||||
run_main(["bundle", BuildSha | ModuleOrder]) when ModuleOrder =/= [] ->
|
||||
write_json_result(build_bundle(type_conv:ensure_binary(BuildSha), ModuleOrder));
|
||||
run_main(["sign", SignerKeyId, PrivateKeyPath, BundlePath]) ->
|
||||
write_json_result(sign_bundle_file(SignerKeyId, PrivateKeyPath, BundlePath));
|
||||
run_main(["append", BuildSha, CreatedBy, EventPath]) ->
|
||||
Result = append_event(
|
||||
type_conv:ensure_binary(BuildSha), type_conv:ensure_binary(CreatedBy), EventPath
|
||||
),
|
||||
write_append_result(Result);
|
||||
run_main(_Args) ->
|
||||
write_usage(),
|
||||
64.
|
||||
|
||||
-spec build_bundle(binary(), [string()]) -> {ok, map()} | {error, term()}.
|
||||
build_bundle(BuildSha, ModuleSpecs) when is_binary(BuildSha), is_list(ModuleSpecs) ->
|
||||
build_bundle_modules(ModuleSpecs, fun(Modules) ->
|
||||
{ok, #{<<"version">> => 1, <<"build_sha">> => BuildSha, <<"modules">> => Modules}}
|
||||
end).
|
||||
|
||||
-spec sign_bundle(binary(), file:filename(), binary()) -> {ok, map()} | {error, term()}.
|
||||
sign_bundle(SignerKeyId, PrivateKeyPath, BundleJson) when
|
||||
is_binary(SignerKeyId), is_binary(BundleJson)
|
||||
->
|
||||
case decode_json(BundleJson) of
|
||||
{ok, Bundle} -> sign_decoded_bundle_with_key(SignerKeyId, PrivateKeyPath, Bundle);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec sign_decoded_bundle_with_key(binary(), file:filename(), term()) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
sign_decoded_bundle_with_key(SignerKeyId, PrivateKeyPath, Bundle) ->
|
||||
case read_private_key(PrivateKeyPath) of
|
||||
{ok, PrivateKey} -> sign_decoded_bundle(SignerKeyId, Bundle, PrivateKey);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec sign_bundle_file(string(), file:filename(), file:filename()) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
sign_bundle_file(SignerKeyId, PrivateKeyPath, BundlePath) ->
|
||||
case file:read_file(BundlePath) of
|
||||
{ok, BundleJson} ->
|
||||
sign_bundle(type_conv:ensure_binary(SignerKeyId), PrivateKeyPath, BundleJson);
|
||||
{error, Reason} ->
|
||||
{error, {read_bundle_failed, Reason}}
|
||||
end.
|
||||
|
||||
-spec sign_decoded_bundle(binary(), term(), binary()) -> {ok, map()} | {error, term()}.
|
||||
sign_decoded_bundle(SignerKeyId, BundleJson, PrivateKey) ->
|
||||
case json_to_bundle(BundleJson) of
|
||||
{ok, Bundle} -> sign_bundle_term(SignerKeyId, Bundle, PrivateKey);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec sign_bundle_term(binary(), map(), binary()) -> {ok, map()} | {error, term()}.
|
||||
sign_bundle_term(SignerKeyId, Bundle, PrivateKey) ->
|
||||
case gateway_hotpatch_bundle:compress_term(Bundle) of
|
||||
{ok, Compressed} -> sign_compressed_bundle(SignerKeyId, Compressed, PrivateKey);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec sign_compressed_bundle(binary(), binary(), binary()) -> {ok, map()} | {error, term()}.
|
||||
sign_compressed_bundle(SignerKeyId, Compressed, PrivateKey) ->
|
||||
case gateway_hotpatch_bundle:sign(Compressed, PrivateKey) of
|
||||
{ok, Signature} -> {ok, signed_event(SignerKeyId, Compressed, Signature)};
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec signed_event(binary(), binary(), binary()) -> map().
|
||||
signed_event(SignerKeyId, Compressed, Signature) ->
|
||||
#{
|
||||
<<"schema_version">> => 1,
|
||||
<<"kind">> => <<"beam_bundle">>,
|
||||
<<"created_by">> => gateway_hotpatch_runtime:node_name(),
|
||||
<<"signer_key_id">> => SignerKeyId,
|
||||
<<"bundle_sha256">> => encode_bytes(gateway_hotpatch_bundle:bundle_hash(Compressed)),
|
||||
<<"signature">> => encode_bytes(Signature),
|
||||
<<"bundle">> => encode_bytes(Compressed)
|
||||
}.
|
||||
|
||||
-spec append_event(binary(), binary(), file:filename()) -> {ok, binary()} | {error, term()}.
|
||||
append_event(BuildSha, CreatedBy, EventPath) ->
|
||||
case file:read_file(EventPath) of
|
||||
{ok, EventJson} -> append_event_json(BuildSha, CreatedBy, EventJson);
|
||||
{error, Reason} -> {error, {read_event_failed, Reason}}
|
||||
end.
|
||||
|
||||
-spec append_event_json(binary(), binary(), binary()) -> {ok, binary()} | {error, term()}.
|
||||
append_event_json(BuildSha, CreatedBy, EventJson) ->
|
||||
case decode_json(EventJson) of
|
||||
{ok, EventJsonTerm} -> append_event_term(BuildSha, CreatedBy, EventJsonTerm);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec append_event_term(binary(), binary(), term()) -> {ok, binary()} | {error, term()}.
|
||||
append_event_term(BuildSha, CreatedBy, EventJsonTerm) ->
|
||||
case json_to_event(EventJsonTerm) of
|
||||
{ok, Event} -> append_event_row(BuildSha, CreatedBy, Event);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec build_bundle_modules([string()], fun(([map()]) -> {ok, map()})) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
build_bundle_modules(ModuleSpecs, Fun) ->
|
||||
case build_bundle_module_list(ModuleSpecs, []) of
|
||||
{ok, Modules} -> Fun(Modules);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec build_bundle_module_list([string()], [map()]) -> {ok, [map()]} | {error, term()}.
|
||||
build_bundle_module_list([], Acc) ->
|
||||
{ok, lists:reverse(Acc)};
|
||||
build_bundle_module_list([ModuleSpec | Rest], Acc) ->
|
||||
case bundle_module(ModuleSpec) of
|
||||
{ok, Module} -> build_bundle_module_list(Rest, [Module | Acc]);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec bundle_module(string()) -> {ok, map()} | {error, term()}.
|
||||
bundle_module(ModuleSpec) ->
|
||||
case split_module_spec(ModuleSpec) of
|
||||
{ok, ModuleName, BeamPath0} -> bundle_named_module(ModuleName, BeamPath0);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec bundle_named_module(string(), file:filename() | undefined) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
bundle_named_module(ModuleName, BeamPath0) ->
|
||||
case existing_module(ModuleName) of
|
||||
{ok, Module} -> bundle_existing_module(Module, BeamPath0);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec bundle_existing_module(atom(), file:filename() | undefined) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
bundle_existing_module(Module, BeamPath0) ->
|
||||
case collect_bundle_parts(Module, BeamPath0) of
|
||||
{ok, CurrentMd5, TargetMd5, CompressedBeam} ->
|
||||
{ok, build_module_entry(Module, CurrentMd5, TargetMd5, CompressedBeam)};
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec collect_bundle_parts(atom(), file:filename() | undefined) ->
|
||||
{ok, binary(), binary(), binary()} | {error, term()}.
|
||||
collect_bundle_parts(Module, BeamPath0) ->
|
||||
case gateway_hotpatch_loader:current_md5(Module) of
|
||||
{ok, CurrentMd5} -> collect_bundle_beam(Module, BeamPath0, CurrentMd5);
|
||||
{error, Reason} -> {error, {current_md5_failed, Module, Reason}}
|
||||
end.
|
||||
|
||||
-spec collect_bundle_beam(atom(), file:filename() | undefined, binary()) ->
|
||||
{ok, binary(), binary(), binary()} | {error, term()}.
|
||||
collect_bundle_beam(Module, BeamPath0, CurrentMd5) ->
|
||||
case resolve_beam_path(Module, BeamPath0) of
|
||||
{ok, BeamPath} -> read_bundle_beam(Module, BeamPath, CurrentMd5);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec read_bundle_beam(atom(), file:filename(), binary()) ->
|
||||
{ok, binary(), binary(), binary()} | {error, term()}.
|
||||
read_bundle_beam(Module, BeamPath, CurrentMd5) ->
|
||||
case file:read_file(BeamPath) of
|
||||
{ok, Beam} -> hash_bundle_beam(Module, CurrentMd5, Beam);
|
||||
{error, Reason} -> {error, {read_beam_failed, Module, BeamPath, Reason}}
|
||||
end.
|
||||
|
||||
-spec hash_bundle_beam(atom(), binary(), binary()) ->
|
||||
{ok, binary(), binary(), binary()} | {error, term()}.
|
||||
hash_bundle_beam(Module, CurrentMd5, Beam) ->
|
||||
case gateway_hotpatch_loader:beam_md5(Beam) of
|
||||
{ok, TargetMd5} -> compress_bundle_beam(Module, CurrentMd5, TargetMd5, Beam);
|
||||
{error, Reason} -> {error, {target_md5_failed, Module, Reason}}
|
||||
end.
|
||||
|
||||
-spec compress_bundle_beam(atom(), binary(), binary(), binary()) ->
|
||||
{ok, binary(), binary(), binary()} | {error, term()}.
|
||||
compress_bundle_beam(Module, CurrentMd5, TargetMd5, Beam) ->
|
||||
case compress_beam(Beam) of
|
||||
{ok, CompressedBeam} -> {ok, CurrentMd5, TargetMd5, CompressedBeam};
|
||||
{error, Reason} -> {error, {beam_compress_failed, Module, Reason}}
|
||||
end.
|
||||
|
||||
-spec build_module_entry(atom(), binary(), binary(), binary()) -> map().
|
||||
build_module_entry(Module, CurrentMd5, TargetMd5, CompressedBeam) ->
|
||||
#{
|
||||
<<"module">> => atom_to_binary(Module, utf8),
|
||||
<<"expected_current_md5">> => encode_bytes(CurrentMd5),
|
||||
<<"target_md5">> => encode_bytes(TargetMd5),
|
||||
<<"beam_zstd">> => encode_bytes(CompressedBeam)
|
||||
}.
|
||||
|
||||
-spec append_event_row(binary(), binary(), map()) -> {ok, binary()} | {error, term()}.
|
||||
append_event_row(BuildSha, CreatedBy, Event) ->
|
||||
_ = fluxer_gateway_env:load(),
|
||||
case gateway_hotpatch_store:connect() of
|
||||
ok ->
|
||||
gateway_hotpatch_store:append_event(
|
||||
BuildSha,
|
||||
CreatedBy,
|
||||
maps:get(signer_key_id, Event),
|
||||
maps:get(signature, Event),
|
||||
maps:get(bundle_sha256, Event),
|
||||
maps:get(bundle, Event)
|
||||
);
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec split_module_spec(string()) ->
|
||||
{ok, string(), file:filename() | undefined} | {error, term()}.
|
||||
split_module_spec(ModuleSpec) ->
|
||||
case string:split(ModuleSpec, "=", leading) of
|
||||
[ModuleName, BeamPath] when ModuleName =/= "", BeamPath =/= "" ->
|
||||
{ok, ModuleName, BeamPath};
|
||||
[ModuleName] when ModuleName =/= "" ->
|
||||
{ok, ModuleName, undefined};
|
||||
_ ->
|
||||
{error, {invalid_module_spec, ModuleSpec}}
|
||||
end.
|
||||
|
||||
-spec existing_module(string()) -> {ok, atom()} | {error, term()}.
|
||||
existing_module(ModuleName) ->
|
||||
try
|
||||
{ok, list_to_existing_atom(ModuleName)}
|
||||
catch
|
||||
error:badarg -> {error, {unknown_module, ModuleName}}
|
||||
end.
|
||||
|
||||
-spec resolve_beam_path(atom(), file:filename() | undefined) ->
|
||||
{ok, file:filename()} | {error, term()}.
|
||||
resolve_beam_path(Module, undefined) ->
|
||||
case code:which(Module) of
|
||||
File when is_list(File) -> {ok, File};
|
||||
Other -> {error, {module_beam_not_found, Module, Other}}
|
||||
end;
|
||||
resolve_beam_path(_Module, BeamPath) ->
|
||||
{ok, BeamPath}.
|
||||
|
||||
-spec compress_beam(binary()) -> {ok, binary()} | {error, term()}.
|
||||
compress_beam(Beam) ->
|
||||
try erlang:apply(ezstd, compress, [Beam, 3]) of
|
||||
Compressed when is_binary(Compressed) -> {ok, Compressed};
|
||||
{error, Reason} -> {error, Reason};
|
||||
Other -> {error, Other}
|
||||
catch
|
||||
Class:Reason -> {error, {Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec decode_json(binary()) -> {ok, term()} | {error, term()}.
|
||||
decode_json(Json) ->
|
||||
try
|
||||
{ok, json:decode(Json)}
|
||||
catch
|
||||
Class:Reason -> {error, {decode_json_failed, Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec read_private_key(file:filename()) -> {ok, binary()} | {error, term()}.
|
||||
read_private_key(Path) ->
|
||||
case file:read_file(Path) of
|
||||
{ok, PrivateKey} when byte_size(PrivateKey) =:= 32 ->
|
||||
{ok, PrivateKey};
|
||||
{ok, PrivateKey} ->
|
||||
{error, {invalid_private_key_size, byte_size(PrivateKey)}};
|
||||
{error, Reason} ->
|
||||
{error, {read_private_key_failed, Reason}}
|
||||
end.
|
||||
|
||||
-spec json_to_bundle(term()) -> {ok, map()} | {error, term()}.
|
||||
json_to_bundle(#{
|
||||
<<"version">> := Version, <<"build_sha">> := BuildSha, <<"modules">> := Modules
|
||||
}) when is_list(Modules) ->
|
||||
case json_to_module_entries(Modules, []) of
|
||||
{ok, Entries} ->
|
||||
{ok, #{
|
||||
<<"version">> => Version,
|
||||
<<"build_sha">> => BuildSha,
|
||||
<<"modules">> => Entries
|
||||
}};
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end;
|
||||
json_to_bundle(Other) ->
|
||||
{error, {invalid_bundle_json, Other}}.
|
||||
|
||||
-spec json_to_module_entries([term()], [map()]) -> {ok, [map()]} | {error, term()}.
|
||||
json_to_module_entries([], Acc) ->
|
||||
{ok, lists:reverse(Acc)};
|
||||
json_to_module_entries([Module | Rest], Acc) ->
|
||||
case json_to_module_entry(Module) of
|
||||
{ok, Entry} -> json_to_module_entries(Rest, [Entry | Acc]);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec json_to_module_entry(term()) -> {ok, map()} | {error, term()}.
|
||||
json_to_module_entry(#{
|
||||
<<"module">> := Module,
|
||||
<<"expected_current_md5">> := ExpectedMd5,
|
||||
<<"target_md5">> := TargetMd5,
|
||||
<<"beam_zstd">> := BeamZstd
|
||||
}) ->
|
||||
decode_module_entry(Module, ExpectedMd5, TargetMd5, BeamZstd);
|
||||
json_to_module_entry(Other) ->
|
||||
{error, {invalid_module_entry_json, Other}}.
|
||||
|
||||
-spec decode_module_entry(term(), term(), term(), term()) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
decode_module_entry(Module, ExpectedMd5, TargetMd5, BeamZstd) when
|
||||
is_binary(Module), is_binary(ExpectedMd5), is_binary(TargetMd5), is_binary(BeamZstd)
|
||||
->
|
||||
case decode_bytes(ExpectedMd5) of
|
||||
{ok, ExpectedMd5Bytes} ->
|
||||
decode_module_entry_target(Module, ExpectedMd5Bytes, TargetMd5, BeamZstd);
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end;
|
||||
decode_module_entry(Module, ExpectedMd5, TargetMd5, BeamZstd) ->
|
||||
{error, {invalid_module_entry_json, {Module, ExpectedMd5, TargetMd5, BeamZstd}}}.
|
||||
|
||||
-spec decode_module_entry_target(binary(), binary(), binary(), binary()) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
decode_module_entry_target(Module, ExpectedMd5, TargetMd5, BeamZstd) ->
|
||||
case decode_bytes(TargetMd5) of
|
||||
{ok, TargetMd5Bytes} ->
|
||||
decode_module_entry_beam(Module, ExpectedMd5, TargetMd5Bytes, BeamZstd);
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec decode_module_entry_beam(binary(), binary(), binary(), binary()) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
decode_module_entry_beam(Module, ExpectedMd5, TargetMd5, BeamZstd) ->
|
||||
case decode_bytes(BeamZstd) of
|
||||
{ok, BeamZstdBytes} ->
|
||||
{ok, #{
|
||||
<<"module">> => Module,
|
||||
<<"expected_current_md5">> => ExpectedMd5,
|
||||
<<"target_md5">> => TargetMd5,
|
||||
<<"beam_zstd">> => BeamZstdBytes
|
||||
}};
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec json_to_event(term()) -> {ok, map()} | {error, term()}.
|
||||
json_to_event(#{
|
||||
<<"signer_key_id">> := SignerKeyId,
|
||||
<<"bundle_sha256">> := BundleSha256,
|
||||
<<"signature">> := Signature,
|
||||
<<"bundle">> := Bundle
|
||||
}) ->
|
||||
decode_event_bytes(SignerKeyId, BundleSha256, Signature, Bundle);
|
||||
json_to_event(Other) ->
|
||||
{error, {invalid_event_json, Other}}.
|
||||
|
||||
-spec decode_event_bytes(term(), term(), term(), term()) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
decode_event_bytes(SignerKeyId, BundleSha256, Signature, Bundle) when
|
||||
is_binary(SignerKeyId), is_binary(BundleSha256), is_binary(Signature), is_binary(Bundle)
|
||||
->
|
||||
case decode_bytes(BundleSha256) of
|
||||
{ok, BundleSha256Bytes} ->
|
||||
decode_event_signature(SignerKeyId, BundleSha256Bytes, Signature, Bundle);
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end;
|
||||
decode_event_bytes(SignerKeyId, BundleSha256, Signature, Bundle) ->
|
||||
{error, {invalid_event_json, {SignerKeyId, BundleSha256, Signature, Bundle}}}.
|
||||
|
||||
-spec decode_event_signature(binary(), binary(), binary(), binary()) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
decode_event_signature(SignerKeyId, BundleSha256, Signature, Bundle) ->
|
||||
case decode_bytes(Signature) of
|
||||
{ok, SignatureBytes} ->
|
||||
decode_event_bundle(SignerKeyId, BundleSha256, SignatureBytes, Bundle);
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec decode_event_bundle(binary(), binary(), binary(), binary()) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
decode_event_bundle(SignerKeyId, BundleSha256, Signature, Bundle) ->
|
||||
case decode_bytes(Bundle) of
|
||||
{ok, BundleBytes} ->
|
||||
{ok, #{
|
||||
signer_key_id => SignerKeyId,
|
||||
bundle_sha256 => BundleSha256,
|
||||
signature => Signature,
|
||||
bundle => BundleBytes
|
||||
}};
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec encode_bytes(binary()) -> binary().
|
||||
encode_bytes(Binary) ->
|
||||
base64:encode(Binary).
|
||||
|
||||
-spec decode_bytes(binary()) -> {ok, binary()} | {error, term()}.
|
||||
decode_bytes(Encoded) when is_binary(Encoded) ->
|
||||
try
|
||||
{ok, base64:decode(Encoded)}
|
||||
catch
|
||||
Class:Reason -> {error, {invalid_base64_bytes, Encoded, Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec write_json_result({ok, map()} | {error, term()}) -> non_neg_integer().
|
||||
write_json_result({ok, Term}) ->
|
||||
write_stdout("~ts~n", [json:encode(Term)]),
|
||||
0;
|
||||
write_json_result({error, Reason}) ->
|
||||
write_error(Reason).
|
||||
|
||||
-spec write_append_result({ok, binary()} | {error, term()}) -> non_neg_integer().
|
||||
write_append_result({ok, EventId}) ->
|
||||
write_stdout("appended hotpatch event ~ts~n", [gateway_hotpatch_loader:hex(EventId)]),
|
||||
0;
|
||||
write_append_result({error, Reason}) ->
|
||||
write_error(Reason).
|
||||
|
||||
-spec write_error(term()) -> non_neg_integer().
|
||||
write_error(Reason) ->
|
||||
write_stderr("gateway hotpatch failed: ~0tp~n", [Reason]),
|
||||
1.
|
||||
|
||||
-spec write_usage() -> ok.
|
||||
write_usage() ->
|
||||
write_stderr(
|
||||
"usage: gateway_hotpatch_cli bundle BUILD_SHA module_a=/path/to/module_a.beam ...~n"
|
||||
" gateway_hotpatch_cli sign SIGNER_KEY_ID PRIVATE_KEY_RAW_FILE bundle.json~n"
|
||||
" gateway_hotpatch_cli append BUILD_SHA CREATED_BY signed-event.json~n",
|
||||
[]
|
||||
).
|
||||
|
||||
-spec write_stdout(io:format(), [term()]) -> ok.
|
||||
write_stdout(Format, Args) ->
|
||||
write_stream(standard_io, Format, Args).
|
||||
|
||||
-spec write_stderr(io:format(), [term()]) -> ok.
|
||||
write_stderr(Format, Args) ->
|
||||
write_stream(standard_error, Format, Args).
|
||||
|
||||
-spec write_stream(file:io_device() | standard_io | standard_error, io:format(), [term()]) ->
|
||||
ok.
|
||||
write_stream(Device, Format, Args) ->
|
||||
Output = iolist_to_binary(io_lib:format(Format, Args)),
|
||||
_ = file:write(Device, Output),
|
||||
ok.
|
||||
@@ -1,433 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(gateway_hotpatch_loader).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([
|
||||
apply_bundle/1,
|
||||
current_md5/1,
|
||||
beam_module/1,
|
||||
beam_md5/1,
|
||||
hex/1
|
||||
]).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-endif.
|
||||
|
||||
-type module_entry() :: map().
|
||||
-type apply_result() :: {ok, applied | skipped, atom(), binary()} | {error, term()}.
|
||||
|
||||
-spec apply_bundle(map()) -> {ok, map()} | {error, term()}.
|
||||
apply_bundle(Bundle) when is_map(Bundle) ->
|
||||
case
|
||||
{
|
||||
entry_value(version, Bundle),
|
||||
entry_value(build_sha, Bundle),
|
||||
entry_value(modules, Bundle)
|
||||
}
|
||||
of
|
||||
{1, BundleBuildSha, Modules} when is_list(Modules) ->
|
||||
apply_versioned_bundle(BundleBuildSha, Modules);
|
||||
_ ->
|
||||
{error, invalid_bundle}
|
||||
end;
|
||||
apply_bundle(_Bundle) ->
|
||||
{error, invalid_bundle}.
|
||||
|
||||
-spec apply_versioned_bundle(term(), [module_entry()]) -> {ok, map()} | {error, term()}.
|
||||
apply_versioned_bundle(BundleBuildSha, Modules) ->
|
||||
CurrentBuildSha = gateway_hotpatch_runtime:build_sha(),
|
||||
case normalize_binary(BundleBuildSha) of
|
||||
CurrentBuildSha -> apply_modules(Modules, #{applied => [], skipped => []});
|
||||
Other -> {error, {build_sha_mismatch, Other, CurrentBuildSha}}
|
||||
end.
|
||||
|
||||
-spec current_md5(atom()) -> {ok, binary()} | {error, term()}.
|
||||
current_md5(Module) when is_atom(Module) ->
|
||||
case hotpatch_loaded_md5(Module) of
|
||||
{ok, Md5} -> {ok, Md5};
|
||||
error -> current_md5_result(Module, current_md5_from_loaded_or_file(Module))
|
||||
end.
|
||||
|
||||
-spec current_md5_result(atom(), {ok, binary()} | {error, term()}) ->
|
||||
{ok, binary()} | {error, term()}.
|
||||
current_md5_result(_Module, {ok, Md5}) ->
|
||||
{ok, Md5};
|
||||
current_md5_result(Module, {error, Reason}) ->
|
||||
fallback_current_md5(Module, Reason).
|
||||
|
||||
-spec fallback_current_md5(atom(), term()) -> {ok, binary()} | {error, term()}.
|
||||
fallback_current_md5(Module, Reason) ->
|
||||
case hotpatch_loaded_md5(Module) of
|
||||
{ok, Md5} -> {ok, Md5};
|
||||
error -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec current_md5_from_loaded_or_file(atom()) -> {ok, binary()} | {error, term()}.
|
||||
current_md5_from_loaded_or_file(Module) ->
|
||||
case code:get_object_code(Module) of
|
||||
{Module, Beam, _File} when is_binary(Beam) -> beam_md5(Beam);
|
||||
error -> current_md5_from_file(Module)
|
||||
end.
|
||||
|
||||
-spec beam_module(binary()) -> {ok, atom()} | {error, term()}.
|
||||
beam_module(Beam) when is_binary(Beam) ->
|
||||
case beam_lib:info(Beam) of
|
||||
Info when is_list(Info) -> beam_module_from_info(Info);
|
||||
Other -> {error, {beam_info_failed, Other}}
|
||||
end.
|
||||
|
||||
-spec beam_module_from_info(list()) -> {ok, atom()} | {error, term()}.
|
||||
beam_module_from_info(Info) ->
|
||||
case lists:keyfind(module, 1, Info) of
|
||||
{module, Module} when is_atom(Module) -> {ok, Module};
|
||||
_ -> {error, module_not_found}
|
||||
end.
|
||||
|
||||
-spec beam_md5(binary()) -> {ok, binary()} | {error, term()}.
|
||||
beam_md5(Beam) when is_binary(Beam) ->
|
||||
case beam_lib:md5(Beam) of
|
||||
{ok, {_Module, Md5}} when is_binary(Md5) -> {ok, Md5};
|
||||
Error -> {error, {beam_md5_failed, Error}}
|
||||
end.
|
||||
|
||||
-spec hex(binary()) -> binary().
|
||||
hex(Binary) when is_binary(Binary) ->
|
||||
iolist_to_binary([[hex_nibble(High), hex_nibble(Low)] || <<High:4, Low:4>> <= Binary]).
|
||||
|
||||
-spec apply_modules([module_entry()], map()) -> {ok, map()} | {error, term()}.
|
||||
apply_modules([], Acc) ->
|
||||
{ok, Acc#{module_count => length(maps:get(applied, Acc)) + length(maps:get(skipped, Acc))}};
|
||||
apply_modules([Entry | Rest], Acc) ->
|
||||
case apply_module(Entry) of
|
||||
{ok, applied, Module, TargetMd5} ->
|
||||
apply_modules(
|
||||
Rest,
|
||||
Acc#{applied => [{Module, hex(TargetMd5)} | maps:get(applied, Acc)]}
|
||||
);
|
||||
{ok, skipped, Module, TargetMd5} ->
|
||||
apply_modules(
|
||||
Rest,
|
||||
Acc#{skipped => [{Module, hex(TargetMd5)} | maps:get(skipped, Acc)]}
|
||||
);
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec apply_module(module_entry()) -> apply_result().
|
||||
apply_module(Entry) ->
|
||||
with_entry(Entry, fun apply_valid_entry/4).
|
||||
|
||||
-spec apply_valid_entry(atom(), binary(), binary(), binary()) -> apply_result().
|
||||
apply_valid_entry(Module, ExpectedMd5, TargetMd5, Beam) ->
|
||||
case current_md5(Module) of
|
||||
{ok, TargetMd5} ->
|
||||
{ok, skipped, Module, TargetMd5};
|
||||
{ok, ExpectedMd5} ->
|
||||
load_module(Module, Beam, TargetMd5);
|
||||
{ok, CurrentMd5} ->
|
||||
{error, {md5_mismatch, Module, hex(CurrentMd5), hex(ExpectedMd5)}};
|
||||
{error, Reason} ->
|
||||
{error, {current_md5_failed, Module, Reason}}
|
||||
end.
|
||||
|
||||
-spec with_entry(module_entry(), fun((atom(), binary(), binary(), binary()) -> apply_result())) ->
|
||||
apply_result().
|
||||
with_entry(Entry, Fun) when is_map(Entry), is_function(Fun, 4) ->
|
||||
case normalize_module(entry_value(module, Entry)) of
|
||||
{ok, Module} ->
|
||||
ExpectedMd5 = entry_value(expected_current_md5, Entry),
|
||||
TargetMd5 = entry_value(target_md5, Entry),
|
||||
BeamZstd = entry_value(beam_zstd, Entry),
|
||||
with_entry_beam(Module, ExpectedMd5, TargetMd5, BeamZstd, Fun);
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end;
|
||||
with_entry(_Entry, _Fun) ->
|
||||
{error, invalid_module_entry}.
|
||||
|
||||
-spec with_entry_beam(atom(), term(), term(), term(), fun(
|
||||
(atom(), binary(), binary(), binary()) -> apply_result()
|
||||
)) ->
|
||||
apply_result().
|
||||
with_entry_beam(Module, ExpectedMd5, TargetMd5, BeamZstd, Fun) when
|
||||
is_binary(ExpectedMd5),
|
||||
is_binary(TargetMd5),
|
||||
byte_size(ExpectedMd5) =:= 16,
|
||||
byte_size(TargetMd5) =:= 16,
|
||||
is_binary(BeamZstd)
|
||||
->
|
||||
case decompress_beam(BeamZstd) of
|
||||
{ok, Beam} -> validate_beam(Module, ExpectedMd5, TargetMd5, Beam, Fun);
|
||||
{error, Reason} -> {error, {beam_decompress_failed, Module, Reason}}
|
||||
end;
|
||||
with_entry_beam(Module, _ExpectedMd5, _TargetMd5, _BeamZstd, _Fun) ->
|
||||
{error, {invalid_module_entry, Module}}.
|
||||
|
||||
-spec validate_beam(atom(), binary(), binary(), binary(), fun(
|
||||
(atom(), binary(), binary(), binary()) -> apply_result()
|
||||
)) ->
|
||||
apply_result().
|
||||
validate_beam(Module, ExpectedMd5, TargetMd5, Beam, Fun) ->
|
||||
case beam_module(Beam) of
|
||||
{ok, Module} -> validate_beam_md5(Module, ExpectedMd5, TargetMd5, Beam, Fun);
|
||||
{ok, OtherModule} -> {error, {beam_module_mismatch, Module, OtherModule}};
|
||||
{error, Reason} -> {error, {beam_module_failed, Module, Reason}}
|
||||
end.
|
||||
|
||||
-spec validate_beam_md5(atom(), binary(), binary(), binary(), fun(
|
||||
(atom(), binary(), binary(), binary()) -> apply_result()
|
||||
)) ->
|
||||
apply_result().
|
||||
validate_beam_md5(Module, ExpectedMd5, TargetMd5, Beam, Fun) ->
|
||||
case beam_md5(Beam) of
|
||||
{ok, TargetMd5} ->
|
||||
Fun(Module, ExpectedMd5, TargetMd5, Beam);
|
||||
{ok, OtherMd5} ->
|
||||
{error, {target_md5_mismatch, Module, hex(OtherMd5), hex(TargetMd5)}};
|
||||
{error, Reason} ->
|
||||
{error, {target_md5_failed, Module, Reason}}
|
||||
end.
|
||||
|
||||
-spec load_module(atom(), binary(), binary()) -> apply_result().
|
||||
load_module(Module, Beam, TargetMd5) ->
|
||||
case code:soft_purge(Module) of
|
||||
true -> load_purged_module(Module, Beam, TargetMd5);
|
||||
false -> {error, {soft_purge_failed, Module}}
|
||||
end.
|
||||
|
||||
-spec load_purged_module(atom(), binary(), binary()) -> apply_result().
|
||||
load_purged_module(Module, Beam, TargetMd5) ->
|
||||
case code:load_binary(Module, atom_to_list(Module) ++ ".beam", Beam) of
|
||||
{module, Module} ->
|
||||
put_hotpatch_loaded_md5(Module, TargetMd5),
|
||||
verify_loaded_module(Module, TargetMd5);
|
||||
{error, Reason} ->
|
||||
{error, {load_binary_failed, Module, Reason}}
|
||||
end.
|
||||
|
||||
-spec verify_loaded_module(atom(), binary()) -> apply_result().
|
||||
verify_loaded_module(Module, TargetMd5) ->
|
||||
case current_md5(Module) of
|
||||
{ok, TargetMd5} ->
|
||||
{ok, applied, Module, TargetMd5};
|
||||
{ok, OtherMd5} ->
|
||||
{error, {post_load_md5_mismatch, Module, hex(OtherMd5), hex(TargetMd5)}};
|
||||
{error, Reason} ->
|
||||
{error, {post_load_md5_failed, Module, Reason}}
|
||||
end.
|
||||
|
||||
-spec current_md5_from_file(atom()) -> {ok, binary()} | {error, term()}.
|
||||
current_md5_from_file(Module) ->
|
||||
case code:which(Module) of
|
||||
File when is_list(File) -> current_md5_from_path(Module, File);
|
||||
preloaded -> {error, preloaded};
|
||||
non_existing -> {error, not_loaded};
|
||||
Other -> {error, {not_loadable, Other}}
|
||||
end.
|
||||
|
||||
-spec current_md5_from_path(atom(), file:filename()) -> {ok, binary()} | {error, term()}.
|
||||
current_md5_from_path(Module, File) ->
|
||||
case beam_lib:md5(File) of
|
||||
{ok, {Module, Md5}} when is_binary(Md5) -> {ok, Md5};
|
||||
Error -> {error, {beam_file_md5_failed, Error}}
|
||||
end.
|
||||
|
||||
-spec hotpatch_loaded_md5(atom()) -> {ok, binary()} | error.
|
||||
hotpatch_loaded_md5(Module) ->
|
||||
case persistent_term:get({?MODULE, loaded_md5, Module}, undefined) of
|
||||
Md5 when is_binary(Md5), byte_size(Md5) =:= 16 -> {ok, Md5};
|
||||
_ -> error
|
||||
end.
|
||||
|
||||
-spec put_hotpatch_loaded_md5(atom(), binary()) -> ok.
|
||||
put_hotpatch_loaded_md5(Module, Md5) ->
|
||||
persistent_term:put({?MODULE, loaded_md5, Module}, Md5).
|
||||
|
||||
-spec decompress_beam(binary()) -> {ok, binary()} | {error, term()}.
|
||||
decompress_beam(Compressed) ->
|
||||
try erlang:apply(ezstd, decompress, [Compressed]) of
|
||||
Beam when is_binary(Beam) -> {ok, Beam};
|
||||
Beam when is_list(Beam) -> {ok, iolist_to_binary(Beam)};
|
||||
{error, Reason} -> {error, Reason};
|
||||
Other -> {error, Other}
|
||||
catch
|
||||
Class:Reason -> {error, {Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec normalize_module(term()) -> {ok, atom()} | {error, term()}.
|
||||
normalize_module(Module) when is_atom(Module) ->
|
||||
{ok, Module};
|
||||
normalize_module(Module) when is_binary(Module) ->
|
||||
try
|
||||
{ok, binary_to_existing_atom(Module, utf8)}
|
||||
catch
|
||||
error:badarg -> {error, {unknown_module, Module}}
|
||||
end;
|
||||
normalize_module(Module) when is_list(Module) ->
|
||||
normalize_module(type_conv:ensure_binary(Module));
|
||||
normalize_module(Module) ->
|
||||
{error, {invalid_module, Module}}.
|
||||
|
||||
-spec normalize_binary(term()) -> binary().
|
||||
normalize_binary(Bin) when is_binary(Bin) -> Bin;
|
||||
normalize_binary(List) when is_list(List) -> type_conv:ensure_binary(List);
|
||||
normalize_binary(Atom) when is_atom(Atom) -> atom_to_binary(Atom, utf8);
|
||||
normalize_binary(Other) -> term_to_binary(Other).
|
||||
|
||||
-spec entry_value(atom(), map()) -> term().
|
||||
entry_value(Key, Entry) ->
|
||||
maps:get(Key, Entry, maps:get(atom_to_binary(Key, utf8), Entry, undefined)).
|
||||
|
||||
-spec hex_nibble(0..15) -> integer().
|
||||
hex_nibble(N) when N < 10 -> $0 + N;
|
||||
hex_nibble(N) -> $a + (N - 10).
|
||||
|
||||
-ifdef(TEST).
|
||||
|
||||
beam_module_and_md5_test() ->
|
||||
{module, ?MODULE} = code:ensure_loaded(?MODULE),
|
||||
{?MODULE, Binary, _File} = code:get_object_code(?MODULE),
|
||||
?assertEqual({ok, ?MODULE}, beam_module(Binary)),
|
||||
{ok, Md5} = beam_md5(Binary),
|
||||
?assertEqual(16, byte_size(Md5)).
|
||||
|
||||
apply_bundle_rejects_build_mismatch_test() ->
|
||||
Bundle = #{version => 1, build_sha => <<"definitely-not-this-build">>, modules => []},
|
||||
?assertMatch({error, {build_sha_mismatch, _, _}}, apply_bundle(Bundle)).
|
||||
|
||||
apply_bundle_accepts_binary_keys_test() ->
|
||||
Bundle = #{
|
||||
<<"version">> => 1,
|
||||
<<"build_sha">> => gateway_hotpatch_runtime:build_sha(),
|
||||
<<"modules">> => []
|
||||
},
|
||||
?assertEqual(
|
||||
{ok, #{applied => [], skipped => [], module_count => 0}}, apply_bundle(Bundle)
|
||||
).
|
||||
|
||||
apply_bundle_loads_new_beam_test() ->
|
||||
Module = gateway_hotpatch_loader_test_target,
|
||||
cleanup_test_module(Module),
|
||||
try
|
||||
Beam1 = compile_test_module(Module, 1),
|
||||
Beam2 = compile_test_module(Module, 2),
|
||||
{module, Module} = load_test_beam(Module, Beam1),
|
||||
?assertEqual(1, erlang:apply(Module, version, [])),
|
||||
{ok, ExpectedMd5} = current_md5(Module),
|
||||
{Entry, TargetMd5} = beam_entry(Module, ExpectedMd5, Beam2),
|
||||
Bundle = #{
|
||||
version => 1,
|
||||
build_sha => gateway_hotpatch_runtime:build_sha(),
|
||||
modules => [Entry]
|
||||
},
|
||||
?assertEqual(
|
||||
{ok, #{applied => [{Module, hex(TargetMd5)}], skipped => [], module_count => 1}},
|
||||
apply_bundle(Bundle)
|
||||
),
|
||||
?assertEqual(2, erlang:apply(Module, version, []))
|
||||
after
|
||||
cleanup_test_module(Module)
|
||||
end.
|
||||
|
||||
apply_bundle_leaves_prior_module_loaded_when_later_entry_fails_test() ->
|
||||
ModuleA = gateway_hotpatch_loader_test_partial_a,
|
||||
ModuleB = gateway_hotpatch_loader_test_partial_b,
|
||||
cleanup_test_module(ModuleA),
|
||||
cleanup_test_module(ModuleB),
|
||||
try
|
||||
BeamA1 = compile_test_module(ModuleA, 1),
|
||||
BeamA2 = compile_test_module(ModuleA, 2),
|
||||
BeamB1 = compile_test_module(ModuleB, 1),
|
||||
BeamB2 = compile_test_module(ModuleB, 2),
|
||||
{module, ModuleA} = load_test_beam(ModuleA, BeamA1),
|
||||
{module, ModuleB} = load_test_beam(ModuleB, BeamB1),
|
||||
{ok, Md5A1} = current_md5(ModuleA),
|
||||
WrongExpectedMd5 = <<0:128>>,
|
||||
{EntryA, _Md5A2} = beam_entry(ModuleA, Md5A1, BeamA2),
|
||||
{EntryB, _Md5B2} = beam_entry(ModuleB, WrongExpectedMd5, BeamB2),
|
||||
Bundle = #{
|
||||
version => 1,
|
||||
build_sha => gateway_hotpatch_runtime:build_sha(),
|
||||
modules => [EntryA, EntryB]
|
||||
},
|
||||
?assertMatch({error, {md5_mismatch, ModuleB, _, _}}, apply_bundle(Bundle)),
|
||||
?assertEqual(2, erlang:apply(ModuleA, version, [])),
|
||||
?assertEqual(1, erlang:apply(ModuleB, version, []))
|
||||
after
|
||||
cleanup_test_module(ModuleA),
|
||||
cleanup_test_module(ModuleB)
|
||||
end.
|
||||
|
||||
hex_test() ->
|
||||
?assertEqual(<<"0001020f10ff">>, hex(<<0, 1, 2, 15, 16, 255>>)).
|
||||
|
||||
compile_test_module(Module, Version) ->
|
||||
Dir = test_compile_dir(),
|
||||
Source = filename:join(Dir, atom_to_list(Module) ++ ".erl"),
|
||||
SourceText = io_lib:format(
|
||||
"-module(~p).~n-export([version/0]).~nversion() -> ~p.~n",
|
||||
[Module, Version]
|
||||
),
|
||||
ok = file:write_file(Source, SourceText),
|
||||
case compile:file(Source, [binary, return_errors, return_warnings]) of
|
||||
{ok, Module, Beam} -> Beam;
|
||||
{ok, Module, Beam, _Warnings} -> Beam;
|
||||
Error -> erlang:error({test_module_compile_failed, Module, Error})
|
||||
end.
|
||||
|
||||
test_compile_dir() ->
|
||||
Root =
|
||||
case os:getenv("TMPDIR") of
|
||||
false -> "/tmp";
|
||||
Value -> Value
|
||||
end,
|
||||
Dir = filename:join(Root, "fluxer_hotpatch_loader_tests"),
|
||||
ok = filelib:ensure_dir(filename:join(Dir, "placeholder")),
|
||||
Dir.
|
||||
|
||||
load_test_beam(Module, Beam) ->
|
||||
cleanup_test_module(Module),
|
||||
BeamPath = filename:join(test_compile_dir(), atom_to_list(Module) ++ ".beam"),
|
||||
ok = file:write_file(BeamPath, Beam),
|
||||
code:load_abs(filename:rootname(BeamPath)).
|
||||
|
||||
beam_entry(Module, ExpectedMd5, TargetBeam) ->
|
||||
{ok, TargetMd5} = beam_md5(TargetBeam),
|
||||
{ok, BeamZstd} = compress_test_beam(TargetBeam),
|
||||
{
|
||||
#{
|
||||
module => Module,
|
||||
expected_current_md5 => ExpectedMd5,
|
||||
target_md5 => TargetMd5,
|
||||
beam_zstd => BeamZstd
|
||||
},
|
||||
TargetMd5
|
||||
}.
|
||||
|
||||
compress_test_beam(Beam) ->
|
||||
try erlang:apply(ezstd, compress, [Beam, 3]) of
|
||||
Compressed when is_binary(Compressed) -> {ok, Compressed};
|
||||
{error, Reason} -> {error, Reason};
|
||||
Other -> {error, Other}
|
||||
catch
|
||||
Class:Reason -> {error, {Class, Reason}}
|
||||
end.
|
||||
|
||||
cleanup_test_module(Module) ->
|
||||
_ = code:soft_purge(Module),
|
||||
_ = code:purge(Module),
|
||||
_ = code:delete(Module),
|
||||
_ = code:purge(Module),
|
||||
erase_hotpatch_loaded_md5(Module),
|
||||
_ = file:delete(filename:join(test_compile_dir(), atom_to_list(Module) ++ ".erl")),
|
||||
_ = file:delete(filename:join(test_compile_dir(), atom_to_list(Module) ++ ".beam")),
|
||||
ok.
|
||||
|
||||
-spec erase_hotpatch_loaded_md5(atom()) -> ok.
|
||||
erase_hotpatch_loaded_md5(Module) ->
|
||||
_ = persistent_term:erase({?MODULE, loaded_md5, Module}),
|
||||
ok.
|
||||
|
||||
-endif.
|
||||
@@ -1,319 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(gateway_hotpatch_reconciler).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-behaviour(gen_server).
|
||||
|
||||
-export([start_link/0, is_ready/0, status/0, reconcile_async/0]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
|
||||
|
||||
-record(state, {
|
||||
enabled = false :: boolean(),
|
||||
build_sha = <<"dev">> :: binary(),
|
||||
public_keys = #{} :: #{binary() => binary()},
|
||||
applied_event_ids = [] :: [term()],
|
||||
applied_count = 0 :: non_neg_integer(),
|
||||
poll_interval_ms = 5000 :: pos_integer(),
|
||||
last_error = undefined :: term()
|
||||
}).
|
||||
|
||||
-type state() :: #state{}.
|
||||
|
||||
-define(SERVER, ?MODULE).
|
||||
-define(POLL, poll).
|
||||
-define(STARTUP_RECONCILE_RETRY_MS, 1000).
|
||||
|
||||
-spec start_link() -> gen_server:start_ret().
|
||||
start_link() ->
|
||||
gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).
|
||||
|
||||
-spec is_ready() -> boolean().
|
||||
is_ready() ->
|
||||
gateway_hotpatch_runtime:is_ready().
|
||||
|
||||
-spec status() -> map().
|
||||
status() ->
|
||||
gateway_hotpatch_runtime:status().
|
||||
|
||||
-spec reconcile_async() -> ok.
|
||||
reconcile_async() ->
|
||||
gen_server:cast(?SERVER, reconcile).
|
||||
|
||||
-spec init([]) -> {ok, state()} | {stop, term()}.
|
||||
init([]) ->
|
||||
case gateway_hotpatch_runtime:is_enabled() of
|
||||
false -> init_disabled();
|
||||
true -> init_enabled()
|
||||
end.
|
||||
|
||||
-spec handle_call(term(), {pid(), term()}, state()) -> {reply, term(), state()}.
|
||||
handle_call(status, _From, State) ->
|
||||
{reply, state_status(State, gateway_hotpatch_runtime:is_ready()), State};
|
||||
handle_call(_Request, _From, State) ->
|
||||
{reply, {error, unsupported_call}, State}.
|
||||
|
||||
-spec handle_cast(term(), state()) -> {noreply, state()}.
|
||||
handle_cast(reconcile, State) ->
|
||||
{noreply, reconcile_and_publish(State)};
|
||||
handle_cast(_Request, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
-spec handle_info(term(), state()) -> {noreply, state()}.
|
||||
handle_info(?POLL, #state{enabled = true} = State) ->
|
||||
NewState = reconcile_and_publish(State),
|
||||
schedule_poll(NewState),
|
||||
{noreply, NewState};
|
||||
handle_info(_Info, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
-spec terminate(term(), state()) -> ok.
|
||||
terminate(_Reason, _State) ->
|
||||
ok.
|
||||
|
||||
-spec code_change(term(), state(), term()) -> {ok, state()}.
|
||||
code_change(_OldVsn, State, _Extra) ->
|
||||
{ok, State}.
|
||||
|
||||
-spec init_disabled() -> {ok, state()}.
|
||||
init_disabled() ->
|
||||
BuildSha = gateway_hotpatch_runtime:build_sha(),
|
||||
State = #state{enabled = false, build_sha = BuildSha},
|
||||
gateway_hotpatch_runtime:put_ready(true),
|
||||
publish_status(State, true),
|
||||
{ok, State}.
|
||||
|
||||
-spec init_enabled() -> {ok, state()} | {stop, term()}.
|
||||
init_enabled() ->
|
||||
gateway_hotpatch_runtime:put_ready(false),
|
||||
BuildSha = gateway_hotpatch_runtime:build_sha(),
|
||||
PollIntervalMs = runtime_pos_integer(hotpatch_poll_interval_ms, 5000),
|
||||
PublicKeyConfig = gateway_hotpatch_runtime:get(hotpatch_public_keys, undefined),
|
||||
case gateway_hotpatch_bundle:parse_public_keys(PublicKeyConfig) of
|
||||
{ok, PublicKeys} when map_size(PublicKeys) > 0 ->
|
||||
State0 = #state{
|
||||
enabled = true,
|
||||
build_sha = BuildSha,
|
||||
public_keys = PublicKeys,
|
||||
poll_interval_ms = max(1000, PollIntervalMs)
|
||||
},
|
||||
publish_status(State0, false),
|
||||
init_enabled_connected(State0);
|
||||
{ok, _Empty} ->
|
||||
init_enabled_config_error(BuildSha, PollIntervalMs, missing_hotpatch_public_keys);
|
||||
{error, Reason} ->
|
||||
init_enabled_config_error(
|
||||
BuildSha, PollIntervalMs, {invalid_hotpatch_public_keys, Reason}
|
||||
)
|
||||
end.
|
||||
|
||||
-spec init_enabled_config_error(binary(), pos_integer(), term()) -> {ok, state()}.
|
||||
init_enabled_config_error(BuildSha, PollIntervalMs, Reason) ->
|
||||
State = #state{
|
||||
enabled = true,
|
||||
build_sha = BuildSha,
|
||||
poll_interval_ms = max(1000, PollIntervalMs),
|
||||
last_error = Reason
|
||||
},
|
||||
gateway_hotpatch_runtime:put_ready(false),
|
||||
publish_status(State, false),
|
||||
logger:error("Gateway hotpatch configuration invalid: ~0tp", [Reason]),
|
||||
schedule_poll(State),
|
||||
{ok, State}.
|
||||
|
||||
-spec init_enabled_connected(state()) -> {ok, state()}.
|
||||
init_enabled_connected(State0) ->
|
||||
finish_enabled_startup(State0).
|
||||
|
||||
-spec finish_enabled_startup(state()) -> {ok, state()}.
|
||||
finish_enabled_startup(State0) ->
|
||||
TimeoutMs = runtime_pos_integer(hotpatch_startup_sync_timeout_ms, 30000),
|
||||
DeadlineMs = erlang:monotonic_time(millisecond) + TimeoutMs,
|
||||
finish_startup_reconcile(State0, startup_reconcile(State0, DeadlineMs)).
|
||||
|
||||
-spec finish_startup_reconcile(state(), {ok, state()} | {error, term()}) -> {ok, state()}.
|
||||
finish_startup_reconcile(_State0, {ok, State}) ->
|
||||
gateway_hotpatch_runtime:put_ready(true),
|
||||
publish_status(State, true),
|
||||
schedule_poll(State),
|
||||
{ok, State};
|
||||
finish_startup_reconcile(State0, {error, Reason}) ->
|
||||
Error = {hotpatch_startup_sync_failed, Reason},
|
||||
State = State0#state{last_error = Error},
|
||||
gateway_hotpatch_runtime:put_ready(false),
|
||||
publish_status(State, false),
|
||||
logger:error("Gateway hotpatch startup sync failed; keeping node unready: ~0tp", [Error]),
|
||||
schedule_poll(State),
|
||||
{ok, State}.
|
||||
|
||||
-spec startup_reconcile(state(), integer()) -> {ok, state()} | {error, term()}.
|
||||
startup_reconcile(State, DeadlineMs) ->
|
||||
case reconcile_once(State) of
|
||||
{ok, NewState} ->
|
||||
{ok, NewState};
|
||||
{error, Reason} ->
|
||||
maybe_retry_startup_reconcile(State, DeadlineMs, Reason)
|
||||
end.
|
||||
|
||||
-spec maybe_retry_startup_reconcile(state(), integer(), term()) ->
|
||||
{ok, state()} | {error, term()}.
|
||||
maybe_retry_startup_reconcile(State, DeadlineMs, Reason) ->
|
||||
case erlang:monotonic_time(millisecond) >= DeadlineMs of
|
||||
true ->
|
||||
{error, Reason};
|
||||
false ->
|
||||
retry_startup_reconcile(State#state{last_error = Reason}, DeadlineMs, Reason)
|
||||
end.
|
||||
|
||||
-spec retry_startup_reconcile(state(), integer(), term()) -> {ok, state()} | {error, term()}.
|
||||
retry_startup_reconcile(State, DeadlineMs, Reason) ->
|
||||
case gateway_retry_timer:wait_until(?STARTUP_RECONCILE_RETRY_MS, DeadlineMs) of
|
||||
ok -> startup_reconcile(State, DeadlineMs);
|
||||
expired -> {error, Reason};
|
||||
{error, _InvalidDelay} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec reconcile_and_publish(state()) -> state().
|
||||
reconcile_and_publish(#state{enabled = false} = State) ->
|
||||
gateway_hotpatch_runtime:put_ready(true),
|
||||
publish_status(State, true),
|
||||
State;
|
||||
reconcile_and_publish(State) ->
|
||||
case reconcile_once(State) of
|
||||
{ok, NewState} ->
|
||||
gateway_hotpatch_runtime:put_ready(true),
|
||||
publish_status(NewState, true),
|
||||
NewState;
|
||||
{error, Reason} ->
|
||||
publish_reconcile_error(State, Reason)
|
||||
end.
|
||||
|
||||
-spec reconcile_once(state()) -> {ok, state()} | {error, term()}.
|
||||
reconcile_once(#state{build_sha = BuildSha} = State) ->
|
||||
case gateway_hotpatch_store:connect() of
|
||||
ok -> fetch_and_apply_events(BuildSha, State);
|
||||
{error, Reason} -> {error, {store_connect_failed, Reason}}
|
||||
end.
|
||||
|
||||
-spec fetch_and_apply_events(binary(), state()) -> {ok, state()} | {error, term()}.
|
||||
fetch_and_apply_events(BuildSha, State) ->
|
||||
case gateway_hotpatch_store:fetch_events(BuildSha) of
|
||||
{ok, Events} -> apply_events(Events, State);
|
||||
{error, Reason} -> {error, {fetch_events_failed, Reason}}
|
||||
end.
|
||||
|
||||
-spec publish_reconcile_error(state(), term()) -> state().
|
||||
publish_reconcile_error(State, {fetch_events_failed, _Reason} = Error) ->
|
||||
ErrorState = State#state{last_error = Error},
|
||||
Ready = gateway_hotpatch_runtime:is_ready(),
|
||||
gateway_hotpatch_runtime:put_ready(Ready),
|
||||
publish_status(ErrorState, Ready),
|
||||
logger:warning("Gateway hotpatch fetch failed; keeping current readiness: ~0tp", [Error]),
|
||||
ErrorState;
|
||||
publish_reconcile_error(State, Reason) ->
|
||||
ErrorState = State#state{last_error = Reason},
|
||||
gateway_hotpatch_runtime:put_ready(false),
|
||||
publish_status(ErrorState, false),
|
||||
logger:error("Gateway hotpatch reconciliation failed: ~0tp", [Reason]),
|
||||
ErrorState.
|
||||
|
||||
-spec apply_events([map()], state()) -> {ok, state()} | {error, term()}.
|
||||
apply_events([], State) ->
|
||||
{ok, State#state{last_error = undefined}};
|
||||
apply_events([Event | Rest], State) ->
|
||||
EventId = maps:get(event_id, Event, undefined),
|
||||
case lists:member(EventId, State#state.applied_event_ids) of
|
||||
true ->
|
||||
apply_events(Rest, State);
|
||||
false ->
|
||||
apply_new_event(EventId, Event, Rest, State)
|
||||
end.
|
||||
|
||||
-spec apply_new_event(term(), map(), [map()], state()) -> {ok, state()} | {error, term()}.
|
||||
apply_new_event(EventId, Event, Rest, State) ->
|
||||
case apply_event(Event, State) of
|
||||
{ok, NewState} -> apply_events(Rest, NewState);
|
||||
{error, Reason} -> {error, {event_apply_failed, EventId, Reason}}
|
||||
end.
|
||||
|
||||
-spec apply_event(map(), state()) -> {ok, state()} | {error, term()}.
|
||||
apply_event(Event, #state{public_keys = PublicKeys, build_sha = BuildSha} = State) ->
|
||||
EventId = maps:get(event_id, Event, undefined),
|
||||
BundleHash = maps:get(bundle_sha256, Event, <<>>),
|
||||
_ = code:ensure_loaded(gateway_hotpatch_loader),
|
||||
case gateway_hotpatch_bundle:decode_signed_event(Event, PublicKeys) of
|
||||
{ok, Bundle} ->
|
||||
apply_decoded_event(BuildSha, EventId, BundleHash, Bundle, State);
|
||||
{error, Reason} ->
|
||||
audit_event(BuildSha, EventId, #{bundle_sha256 => BundleHash}, {error, Reason}),
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec apply_decoded_event(binary(), term(), binary(), map(), state()) ->
|
||||
{ok, state()} | {error, term()}.
|
||||
apply_decoded_event(BuildSha, EventId, BundleHash, Bundle, State) ->
|
||||
Summary0 = #{bundle_sha256 => BundleHash},
|
||||
case gateway_hotpatch_loader:apply_bundle(Bundle) of
|
||||
{ok, Summary} ->
|
||||
handle_applied_event(BuildSha, EventId, Summary0, Summary, State);
|
||||
{error, Reason} ->
|
||||
audit_event(BuildSha, EventId, Summary0, {error, Reason}),
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec handle_applied_event(binary(), term(), map(), map(), state()) -> {ok, state()}.
|
||||
handle_applied_event(BuildSha, EventId, Summary0, Summary, State) ->
|
||||
AuditSummary = maps:merge(Summary0, Summary),
|
||||
audit_event(BuildSha, EventId, AuditSummary, ok),
|
||||
logger:notice("Applied gateway hotpatch event ~0tp summary=~0tp", [EventId, AuditSummary]),
|
||||
{ok, State#state{
|
||||
applied_event_ids = [EventId | State#state.applied_event_ids],
|
||||
applied_count = State#state.applied_count + 1,
|
||||
last_error = undefined
|
||||
}}.
|
||||
|
||||
-spec audit_event(binary(), term(), map(), ok | {error, term()}) -> ok.
|
||||
audit_event(BuildSha, EventId, Summary, Result) when is_binary(EventId) ->
|
||||
case
|
||||
gateway_hotpatch_store:audit_applied(
|
||||
BuildSha, gateway_hotpatch_runtime:node_name(), EventId, Summary, Result
|
||||
)
|
||||
of
|
||||
ok ->
|
||||
ok;
|
||||
{error, Reason} ->
|
||||
logger:warning("Gateway hotpatch audit write failed: ~0tp", [Reason]),
|
||||
ok
|
||||
end;
|
||||
audit_event(_BuildSha, _EventId, _Summary, _Result) ->
|
||||
ok.
|
||||
|
||||
-spec publish_status(state(), boolean()) -> ok.
|
||||
publish_status(State, Ready) ->
|
||||
gateway_hotpatch_runtime:put_status(state_status(State, Ready)).
|
||||
|
||||
-spec state_status(state(), boolean()) -> map().
|
||||
state_status(State, Ready) ->
|
||||
#{
|
||||
enabled => State#state.enabled,
|
||||
ready => Ready,
|
||||
build_sha => State#state.build_sha,
|
||||
applied_count => State#state.applied_count,
|
||||
applied_event_count => length(State#state.applied_event_ids),
|
||||
last_error => State#state.last_error
|
||||
}.
|
||||
|
||||
-spec schedule_poll(state()) -> ok.
|
||||
schedule_poll(#state{poll_interval_ms = PollIntervalMs}) ->
|
||||
erlang:send_after(PollIntervalMs, self(), ?POLL),
|
||||
ok.
|
||||
|
||||
-spec runtime_pos_integer(atom(), pos_integer()) -> pos_integer().
|
||||
runtime_pos_integer(Key, Default) ->
|
||||
normalize_pos_integer(gateway_hotpatch_runtime:get(Key, Default), Default).
|
||||
|
||||
-spec normalize_pos_integer(term(), pos_integer()) -> pos_integer().
|
||||
normalize_pos_integer(Value, _Default) when is_integer(Value), Value > 0 ->
|
||||
Value;
|
||||
normalize_pos_integer(_Value, Default) ->
|
||||
Default.
|
||||
@@ -1,78 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(gateway_hotpatch_runtime).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([
|
||||
build_sha/0,
|
||||
node_name/0,
|
||||
is_enabled/0,
|
||||
get/2,
|
||||
put_ready/1,
|
||||
is_ready/0,
|
||||
put_status/1,
|
||||
status/0
|
||||
]).
|
||||
|
||||
-define(READY_KEY, {gateway_hotpatch, ready}).
|
||||
-define(STATUS_KEY, {gateway_hotpatch, status}).
|
||||
|
||||
-spec build_sha() -> binary().
|
||||
build_sha() ->
|
||||
case os:getenv("BUILD_SHA") of
|
||||
false -> build_version();
|
||||
"" -> build_version();
|
||||
Value -> type_conv:ensure_binary(Value, <<"dev">>)
|
||||
end.
|
||||
|
||||
-spec build_version() -> binary().
|
||||
build_version() ->
|
||||
case os:getenv("BUILD_VERSION") of
|
||||
false -> <<"dev">>;
|
||||
"" -> <<"dev">>;
|
||||
Value -> type_conv:ensure_binary(Value, <<"dev">>)
|
||||
end.
|
||||
|
||||
-spec node_name() -> binary().
|
||||
node_name() ->
|
||||
atom_to_binary(node(), utf8).
|
||||
|
||||
-spec is_enabled() -> boolean().
|
||||
is_enabled() ->
|
||||
case fluxer_gateway_env:get(hotpatch_enabled) of
|
||||
true -> true;
|
||||
_ -> false
|
||||
end.
|
||||
|
||||
-spec get(atom(), term()) -> term().
|
||||
get(Key, Default) ->
|
||||
case fluxer_gateway_env:get(Key) of
|
||||
undefined -> Default;
|
||||
Value -> Value
|
||||
end.
|
||||
|
||||
-spec put_ready(boolean()) -> ok.
|
||||
put_ready(Ready) when is_boolean(Ready) ->
|
||||
persistent_term:put(?READY_KEY, Ready),
|
||||
ok.
|
||||
|
||||
-spec is_ready() -> boolean().
|
||||
is_ready() ->
|
||||
case persistent_term:get(?READY_KEY, true) of
|
||||
false -> false;
|
||||
_ -> true
|
||||
end.
|
||||
|
||||
-spec put_status(map()) -> ok.
|
||||
put_status(Status) ->
|
||||
persistent_term:put(?STATUS_KEY, Status),
|
||||
ok.
|
||||
|
||||
-spec status() -> map().
|
||||
status() ->
|
||||
persistent_term:get(?STATUS_KEY, #{
|
||||
enabled => false,
|
||||
ready => true,
|
||||
build_sha => build_sha(),
|
||||
applied_count => 0
|
||||
}).
|
||||
@@ -1,277 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(gateway_hotpatch_store).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([
|
||||
connect/0,
|
||||
fetch_events/1,
|
||||
append_event/5,
|
||||
append_event/6,
|
||||
audit_applied/5,
|
||||
normalize_event_rows/1
|
||||
]).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-endif.
|
||||
|
||||
-define(STMT_FETCH_EVENTS, gateway_hotpatch_fetch_events_by_build).
|
||||
-define(STMT_APPEND_EVENT, gateway_hotpatch_append_event).
|
||||
-define(STMT_AUDIT_APPLIED, gateway_hotpatch_audit_applied).
|
||||
|
||||
-spec connect() -> ok | {error, term()}.
|
||||
connect() ->
|
||||
case gateway_hotpatch_runtime:get(hotpatch_cassandra_hosts, undefined) of
|
||||
Hosts when is_list(Hosts), Hosts =/= [] -> connect_hosts(Hosts);
|
||||
Hosts when is_binary(Hosts), byte_size(Hosts) > 0 ->
|
||||
start_erlcass(binary_to_list(Hosts));
|
||||
_ ->
|
||||
{error, missing_hotpatch_cassandra_hosts}
|
||||
end.
|
||||
|
||||
-spec connect_hosts(term()) -> ok | {error, term()}.
|
||||
connect_hosts(Hosts) ->
|
||||
case type_conv:to_list(Hosts) of
|
||||
String when is_list(String), String =/= [] -> start_erlcass(String);
|
||||
_ -> {error, missing_hotpatch_cassandra_hosts}
|
||||
end.
|
||||
|
||||
-spec fetch_events(binary()) -> {ok, [map()]} | {error, term()}.
|
||||
fetch_events(BuildSha) when is_binary(BuildSha) ->
|
||||
case erlcass:execute(?STMT_FETCH_EVENTS, [BuildSha]) of
|
||||
{ok, _Columns, Rows} -> {ok, normalize_event_rows(Rows)};
|
||||
ok -> {ok, []};
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec append_event(binary(), binary(), binary(), binary(), binary()) ->
|
||||
{ok, binary()} | {error, term()}.
|
||||
append_event(CreatedBy, SignerKeyId, Signature, BundleSha256, Bundle) when
|
||||
is_binary(CreatedBy),
|
||||
is_binary(SignerKeyId),
|
||||
is_binary(Signature),
|
||||
is_binary(BundleSha256),
|
||||
is_binary(Bundle)
|
||||
->
|
||||
BuildSha = gateway_hotpatch_runtime:build_sha(),
|
||||
append_event(BuildSha, CreatedBy, SignerKeyId, Signature, BundleSha256, Bundle).
|
||||
|
||||
-spec append_event(binary(), binary(), binary(), binary(), binary(), binary()) ->
|
||||
{ok, binary()} | {error, term()}.
|
||||
append_event(BuildSha, CreatedBy, SignerKeyId, Signature, BundleSha256, Bundle) when
|
||||
is_binary(BuildSha),
|
||||
is_binary(CreatedBy),
|
||||
is_binary(SignerKeyId),
|
||||
is_binary(Signature),
|
||||
is_binary(BundleSha256),
|
||||
is_binary(Bundle)
|
||||
->
|
||||
case erlcass_uuid:gen_time() of
|
||||
{ok, EventId} ->
|
||||
Params = append_event_params(
|
||||
BuildSha, EventId, CreatedBy, SignerKeyId, BundleSha256, Signature, Bundle
|
||||
),
|
||||
execute_append_event(EventId, Params);
|
||||
{error, Reason} ->
|
||||
{error, {event_id_failed, Reason}}
|
||||
end.
|
||||
|
||||
-spec append_event_params(
|
||||
binary(), binary(), binary(), binary(), binary(), binary(), binary()
|
||||
) -> list().
|
||||
append_event_params(BuildSha, EventId, CreatedBy, SignerKeyId, BundleSha256, Signature, Bundle) ->
|
||||
[
|
||||
BuildSha,
|
||||
EventId,
|
||||
1,
|
||||
<<"beam_bundle">>,
|
||||
CreatedBy,
|
||||
SignerKeyId,
|
||||
BundleSha256,
|
||||
Signature,
|
||||
Bundle
|
||||
].
|
||||
|
||||
-spec execute_append_event(binary(), list()) -> {ok, binary()} | {error, term()}.
|
||||
execute_append_event(EventId, Params) ->
|
||||
case erlcass:execute(?STMT_APPEND_EVENT, Params) of
|
||||
ok -> {ok, EventId};
|
||||
{ok, _Columns, _Rows} -> {ok, EventId};
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec audit_applied(binary(), binary(), binary(), map(), ok | {error, term()}) ->
|
||||
ok | {error, term()}.
|
||||
audit_applied(BuildSha, NodeName, EventId, Summary, Result) when
|
||||
is_binary(BuildSha), is_binary(NodeName), is_binary(EventId), is_map(Summary)
|
||||
->
|
||||
ModuleCount = maps:get(module_count, Summary, 0),
|
||||
BundleSha256 = maps:get(bundle_sha256, Summary, <<>>),
|
||||
Status = audit_status(Result),
|
||||
Error = audit_error(Result),
|
||||
Params = [BuildSha, NodeName, EventId, ModuleCount, BundleSha256, Status, Error],
|
||||
case erlcass:execute(?STMT_AUDIT_APPLIED, Params) of
|
||||
ok -> ok;
|
||||
{ok, _Columns, _Rows} -> ok;
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec normalize_event_rows([list()]) -> [map()].
|
||||
normalize_event_rows(Rows) when is_list(Rows) ->
|
||||
lists:filtermap(fun normalize_event_row/1, Rows).
|
||||
|
||||
-spec start_erlcass(string()) -> ok | {error, term()}.
|
||||
start_erlcass(Hosts) ->
|
||||
Keyspace = gateway_hotpatch_runtime:get(hotpatch_cassandra_keyspace, <<"fluxer">>),
|
||||
Port = gateway_hotpatch_runtime:get(hotpatch_cassandra_port, 9042),
|
||||
Options0 = [
|
||||
{contact_points, type_conv:ensure_binary(Hosts)},
|
||||
{port, Port},
|
||||
{latency_aware_routing, true},
|
||||
{token_aware_routing, true},
|
||||
{tcp_nodelay, true},
|
||||
{tcp_keepalive, {true, 60}},
|
||||
{connect_timeout, 5000},
|
||||
{request_timeout, 5000},
|
||||
{retry_policy, {default, true}},
|
||||
{default_consistency_level, 6}
|
||||
],
|
||||
case maybe_credentials(Options0) of
|
||||
{ok, Options} -> start_erlcass_with_options(Keyspace, Options);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec start_erlcass_with_options(binary(), list()) -> ok | {error, term()}.
|
||||
start_erlcass_with_options(Keyspace, Options) ->
|
||||
application:set_env(erlcass, keyspace, Keyspace),
|
||||
application:set_env(erlcass, cluster_options, Options),
|
||||
application:set_env(erlcass, log_level, 2),
|
||||
case application:ensure_all_started(erlcass) of
|
||||
{ok, _Apps} -> prepare_statements();
|
||||
{error, {erlcass, {already_started, erlcass}}} -> prepare_statements();
|
||||
{error, Reason} -> {error, {erlcass_start_failed, Reason}}
|
||||
end.
|
||||
|
||||
-spec prepare_statements() -> ok | {error, term()}.
|
||||
prepare_statements() ->
|
||||
Statements = [
|
||||
{?STMT_FETCH_EVENTS, <<
|
||||
"SELECT event_id, schema_version, kind, created_by, signer_key_id, "
|
||||
"bundle_sha256, signature, bundle "
|
||||
"FROM gateway_hotpatch_events_by_build WHERE build_sha = ?"
|
||||
>>},
|
||||
{?STMT_APPEND_EVENT, <<
|
||||
"INSERT INTO gateway_hotpatch_events_by_build "
|
||||
"(build_sha, event_id, schema_version, kind, created_at, created_by, "
|
||||
"signer_key_id, bundle_sha256, signature, bundle) "
|
||||
"VALUES (?, ?, ?, ?, toTimestamp(now()), ?, ?, ?, ?, ?)"
|
||||
>>},
|
||||
{?STMT_AUDIT_APPLIED, <<
|
||||
"INSERT INTO gateway_hotpatch_applied_by_node "
|
||||
"(build_sha, node_name, event_id, applied_at, module_count, bundle_sha256, "
|
||||
"status, error) "
|
||||
"VALUES (?, ?, ?, toTimestamp(now()), ?, ?, ?, ?)"
|
||||
>>}
|
||||
],
|
||||
prepare_statements(Statements).
|
||||
|
||||
-spec prepare_statements([{atom(), binary()}]) -> ok | {error, term()}.
|
||||
prepare_statements([]) ->
|
||||
ok;
|
||||
prepare_statements([{Id, Query} | Rest]) ->
|
||||
case prepare_statement(Id, Query) of
|
||||
ok -> prepare_statements(Rest);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec prepare_statement(atom(), binary()) -> ok | {error, term()}.
|
||||
prepare_statement(Id, Query) ->
|
||||
case erlcass:add_prepare_statement(Id, Query) of
|
||||
ok -> ok;
|
||||
{error, already_exist} -> ok;
|
||||
{error, Reason} -> {error, {prepare_failed, Id, Reason}}
|
||||
end.
|
||||
|
||||
-spec maybe_credentials(list()) -> {ok, list()} | {error, term()}.
|
||||
maybe_credentials(Options) ->
|
||||
Username = gateway_hotpatch_runtime:get(hotpatch_cassandra_username, undefined),
|
||||
Password = gateway_hotpatch_runtime:get(hotpatch_cassandra_password, undefined),
|
||||
case {Username, Password} of
|
||||
{U, P} when is_binary(U), byte_size(U) > 0, is_binary(P), byte_size(P) > 0 ->
|
||||
{ok, [{credentials, {U, P}} | Options]};
|
||||
_ ->
|
||||
{error, missing_hotpatch_cassandra_credentials}
|
||||
end.
|
||||
|
||||
-spec normalize_event_row(list()) -> {true, map()} | false.
|
||||
normalize_event_row([
|
||||
EventId,
|
||||
SchemaVersion,
|
||||
Kind,
|
||||
CreatedBy,
|
||||
SignerKeyId,
|
||||
BundleSha256,
|
||||
Signature,
|
||||
Bundle
|
||||
]) when
|
||||
is_binary(EventId),
|
||||
is_binary(SignerKeyId),
|
||||
is_binary(BundleSha256),
|
||||
is_binary(Signature),
|
||||
is_binary(Bundle)
|
||||
->
|
||||
{true, #{
|
||||
event_id => EventId,
|
||||
schema_version => SchemaVersion,
|
||||
kind => Kind,
|
||||
created_by => CreatedBy,
|
||||
signer_key_id => SignerKeyId,
|
||||
bundle_sha256 => BundleSha256,
|
||||
signature => Signature,
|
||||
bundle => Bundle
|
||||
}};
|
||||
normalize_event_row(_Row) ->
|
||||
false.
|
||||
|
||||
-spec audit_status(ok | {error, term()}) -> binary().
|
||||
audit_status(ok) -> <<"ok">>;
|
||||
audit_status({error, _Reason}) -> <<"error">>.
|
||||
|
||||
-spec audit_error(ok | {error, term()}) -> binary().
|
||||
audit_error(ok) ->
|
||||
<<>>;
|
||||
audit_error({error, Reason}) ->
|
||||
iolist_to_binary(io_lib:format("~0tp", [Reason])).
|
||||
|
||||
-ifdef(TEST).
|
||||
|
||||
normalize_event_rows_test() ->
|
||||
EventId = <<1:128>>,
|
||||
Row = [
|
||||
EventId,
|
||||
1,
|
||||
<<"beam_bundle">>,
|
||||
<<"hampus">>,
|
||||
<<"ops">>,
|
||||
<<2:256>>,
|
||||
<<3:512>>,
|
||||
<<"bundle">>
|
||||
],
|
||||
?assertEqual(
|
||||
[
|
||||
#{
|
||||
event_id => EventId,
|
||||
schema_version => 1,
|
||||
kind => <<"beam_bundle">>,
|
||||
created_by => <<"hampus">>,
|
||||
signer_key_id => <<"ops">>,
|
||||
bundle_sha256 => <<2:256>>,
|
||||
signature => <<3:512>>,
|
||||
bundle => <<"bundle">>
|
||||
}
|
||||
],
|
||||
normalize_event_rows([Row, [invalid]])
|
||||
).
|
||||
|
||||
-endif.
|
||||
@@ -98,7 +98,7 @@ fallback_owner_for_role(Role) ->
|
||||
|
||||
-spec is_ready() -> boolean().
|
||||
is_ready() ->
|
||||
not is_draining() andalso gateway_hotpatch_reconciler:is_ready().
|
||||
not is_draining().
|
||||
|
||||
-spec is_draining() -> boolean().
|
||||
is_draining() ->
|
||||
|
||||
@@ -23,7 +23,6 @@ render_metrics() ->
|
||||
render_cluster_counters(),
|
||||
render_process_counts(),
|
||||
render_push_dispatcher_stats(),
|
||||
render_hotpatch_metrics(),
|
||||
render_vm_metrics()
|
||||
].
|
||||
|
||||
@@ -256,33 +255,6 @@ render_push_dispatcher_stats() ->
|
||||
]
|
||||
end.
|
||||
|
||||
-spec render_hotpatch_metrics() -> iolist().
|
||||
render_hotpatch_metrics() ->
|
||||
Status = safe_apply_map(fun gateway_hotpatch_reconciler:status/0),
|
||||
Ready = maps:get(ready, Status, true),
|
||||
Enabled = maps:get(enabled, Status, false),
|
||||
AppliedCount = maps:get(applied_count, Status, 0),
|
||||
[
|
||||
format_metric(
|
||||
<<"fluxer_gateway_hotpatch_enabled">>,
|
||||
<<"gauge">>,
|
||||
<<"Gateway hotpatch reconciler enabled">>,
|
||||
bool_metric(Enabled)
|
||||
),
|
||||
format_metric(
|
||||
<<"fluxer_gateway_hotpatch_ready">>,
|
||||
<<"gauge">>,
|
||||
<<"Gateway hotpatch reconciliation readiness">>,
|
||||
bool_metric(Ready)
|
||||
),
|
||||
format_metric(
|
||||
<<"fluxer_gateway_hotpatch_applied_events_total">>,
|
||||
<<"counter">>,
|
||||
<<"Gateway hotpatch events applied on this node">>,
|
||||
integer_to_binary(AppliedCount)
|
||||
)
|
||||
].
|
||||
|
||||
-spec render_vm_metrics() -> iolist().
|
||||
render_vm_metrics() ->
|
||||
Memory = safe_apply_list(fun erlang:memory/0),
|
||||
@@ -376,7 +348,3 @@ format_labeled_series(Name, Type, Help, LabelValues) ->
|
||||
|| {Label, Value} <- LabelValues
|
||||
]
|
||||
].
|
||||
|
||||
-spec bool_metric(term()) -> binary().
|
||||
bool_metric(true) -> <<"1">>;
|
||||
bool_metric(_) -> <<"0">>.
|
||||
|
||||
@@ -159,7 +159,7 @@ flush_pending_syncs_clears_empty_batch_test() ->
|
||||
State = #{?SYNC_BATCH_STATE_KEY => #{pending_list_ids => #{}}},
|
||||
?assertEqual(#{}, flush_pending_syncs(State)).
|
||||
|
||||
queue_list_sync_drains_pre_hotpatch_pending_batch_test() ->
|
||||
queue_list_sync_drains_preexisting_pending_batch_test() ->
|
||||
Ref = erlang:send_after(60000, self(), ?FLUSH_SYNC_BATCH_MSG),
|
||||
State = #{
|
||||
?SYNC_BATCH_STATE_KEY => #{
|
||||
|
||||
@@ -11,6 +11,9 @@ use std::str::FromStr;
|
||||
use tokio_postgres::{Config as PgConfig, Row, config::SslMode, types::ToSql};
|
||||
use tokio_postgres_rustls::MakeRustlsConnect;
|
||||
|
||||
const POSTGRES_KV_SCHEMA_LOCK_NAMESPACE: i32 = 0x4658_4b56;
|
||||
const POSTGRES_KV_SCHEMA_LOCK_TIMEOUT: &str = "120s";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PostgresConfig {
|
||||
pub url: Option<String>,
|
||||
@@ -134,8 +137,30 @@ pub async fn ensure_kv_schema(pool: &Pool, kv_table: &str) -> anyhow::Result<()>
|
||||
let messages_message_index = quote_identifier(&format!("{kv_table}_messages_message_idx"))?;
|
||||
let message_reactions_message_index =
|
||||
quote_identifier(&format!("{kv_table}_message_reactions_message_idx"))?;
|
||||
let client = pool.get().await?;
|
||||
client
|
||||
let mut client = pool.get().await?;
|
||||
let transaction = client
|
||||
.transaction()
|
||||
.await
|
||||
.context("failed to begin Postgres KV schema transaction")?;
|
||||
transaction
|
||||
.query_one(
|
||||
"SELECT set_config('statement_timeout', $1, true)",
|
||||
&[&POSTGRES_KV_SCHEMA_LOCK_TIMEOUT],
|
||||
)
|
||||
.await
|
||||
.context("failed to configure Postgres KV schema lock timeout")?;
|
||||
transaction
|
||||
.query_one(
|
||||
"SELECT pg_advisory_xact_lock($1, hashtext($2))",
|
||||
&[&POSTGRES_KV_SCHEMA_LOCK_NAMESPACE, &kv_table],
|
||||
)
|
||||
.await
|
||||
.context("failed to acquire Postgres KV schema lock")?;
|
||||
transaction
|
||||
.query_one("SELECT set_config('statement_timeout', '0', true)", &[])
|
||||
.await
|
||||
.context("failed to clear Postgres KV schema lock timeout")?;
|
||||
transaction
|
||||
.batch_execute(&format!(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS {table} (
|
||||
@@ -162,6 +187,10 @@ DROP INDEX IF EXISTS {old_partition_index};
|
||||
))
|
||||
.await
|
||||
.context("failed to ensure Postgres KV schema")?;
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.context("failed to commit Postgres KV schema transaction")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+4
-5
@@ -13,11 +13,10 @@
|
||||
"dev:desktop:canary": "cargo run -p fluxer-dev -- desktop canary",
|
||||
"dev:desktop:package": "cargo run -p fluxer-dev -- desktop package",
|
||||
"dev:desktop:run": "cargo run -p fluxer-dev -- desktop run",
|
||||
"dev:infra:down": "docker compose -f .devcontainer/docker-compose.yml down",
|
||||
"dev:infra:restart": "pnpm dev:infra:down && pnpm dev:infra:up",
|
||||
"dev:infra:up": "pnpm dev:infra:up:lite",
|
||||
"dev:infra:up:full": "docker compose -f .devcontainer/docker-compose.yml up -d postgres cassandra valkey nats livekit elasticsearch meilisearch mailpit",
|
||||
"dev:infra:up:lite": "docker compose -f .devcontainer/docker-compose.yml up -d postgres valkey nats livekit meilisearch mailpit",
|
||||
"dev:infra:restart": "pnpm dev:infra:stop && pnpm dev:infra:start",
|
||||
"dev:infra:start": "cargo run -p fluxer-dev -- infra start",
|
||||
"dev:infra:status": "cargo run -p fluxer-dev -- infra status",
|
||||
"dev:infra:stop": "cargo run -p fluxer-dev -- infra stop",
|
||||
"dev:services": "cargo run -p fluxer-dev -- rust-services",
|
||||
"dev:tunnel": "cargo run -p fluxer-dev -- dev --cloudflare-tunnel",
|
||||
"dev:tunnel:configure": "cargo run -p fluxer-dev -- tunnel configure",
|
||||
|
||||
@@ -195,6 +195,7 @@ function defaultConfig(): MasterConfig {
|
||||
api_key: '',
|
||||
api_secret: '',
|
||||
url: '',
|
||||
internal_url: '',
|
||||
webhook_url: '',
|
||||
},
|
||||
search: {
|
||||
|
||||
@@ -243,6 +243,7 @@ export interface MasterConfig {
|
||||
api_key: string;
|
||||
api_secret: string;
|
||||
url: string;
|
||||
internal_url: string;
|
||||
webhook_url: string;
|
||||
default_region?: {
|
||||
id: string;
|
||||
|
||||
@@ -266,6 +266,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
FLUXER_LIVEKIT_API_KEY: {path: ['integrations', 'voice', 'api_key']},
|
||||
FLUXER_LIVEKIT_API_SECRET: {path: ['integrations', 'voice', 'api_secret']},
|
||||
FLUXER_LIVEKIT_URL: {path: ['integrations', 'voice', 'url']},
|
||||
FLUXER_LIVEKIT_INTERNAL_URL: {path: ['integrations', 'voice', 'internal_url']},
|
||||
FLUXER_LIVEKIT_WEBHOOK_URL: {path: ['integrations', 'voice', 'webhook_url']},
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION: {path: ['integrations', 'voice', 'default_region'], parse: parseEnvValue},
|
||||
FLUXER_SEARCH_ENGINE: {path: ['integrations', 'search', 'engine']},
|
||||
|
||||
Generated
+5
-5
@@ -7701,8 +7701,8 @@ packages:
|
||||
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
caniuse-lite@1.0.30001774:
|
||||
resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==}
|
||||
caniuse-lite@1.0.30001810:
|
||||
resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
|
||||
|
||||
cassandra-driver@4.8.0:
|
||||
resolution: {integrity: sha512-HritfMGq9V7SuESeSodHvArs0mLuMk7uh+7hQK2lqdvXrvm50aWxb4RPxkK3mPDdsgHjJ427xNRFITMH2ei+Sw==}
|
||||
@@ -17551,7 +17551,7 @@ snapshots:
|
||||
autoprefixer@10.4.24(postcss@8.5.6):
|
||||
dependencies:
|
||||
browserslist: 4.28.1
|
||||
caniuse-lite: 1.0.30001774
|
||||
caniuse-lite: 1.0.30001810
|
||||
fraction.js: 5.3.4
|
||||
picocolors: 1.1.1
|
||||
postcss: 8.5.6
|
||||
@@ -17666,7 +17666,7 @@ snapshots:
|
||||
browserslist@4.28.1:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.10.0
|
||||
caniuse-lite: 1.0.30001774
|
||||
caniuse-lite: 1.0.30001810
|
||||
electron-to-chromium: 1.5.302
|
||||
node-releases: 2.0.27
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.1)
|
||||
@@ -17761,7 +17761,7 @@ snapshots:
|
||||
|
||||
camelcase@8.0.0: {}
|
||||
|
||||
caniuse-lite@1.0.30001774: {}
|
||||
caniuse-lite@1.0.30001810: {}
|
||||
|
||||
cassandra-driver@4.8.0:
|
||||
dependencies:
|
||||
|
||||
+123
-51
@@ -4,9 +4,11 @@ use crate::app_wasm::resolve_app_dir;
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use clap::Args;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
@@ -23,11 +25,51 @@ pub struct AppDevServerArgs {
|
||||
app_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(untagged)]
|
||||
enum InputFingerprint {
|
||||
Sha256 {
|
||||
sha256: String,
|
||||
#[serde(skip)]
|
||||
legacy_timestamp_ms: Option<f64>,
|
||||
},
|
||||
LegacyTimestamp(f64),
|
||||
}
|
||||
|
||||
impl InputFingerprint {
|
||||
fn matches(&self, current: &Self) -> bool {
|
||||
match (self, current) {
|
||||
(
|
||||
Self::Sha256 { sha256, .. },
|
||||
Self::Sha256 {
|
||||
sha256: current, ..
|
||||
},
|
||||
) => sha256 == current,
|
||||
(
|
||||
Self::LegacyTimestamp(timestamp),
|
||||
Self::Sha256 {
|
||||
legacy_timestamp_ms: Some(current),
|
||||
..
|
||||
},
|
||||
) => timestamp == current,
|
||||
(Self::LegacyTimestamp(timestamp), Self::LegacyTimestamp(current)) => {
|
||||
timestamp == current
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_legacy(&self) -> bool {
|
||||
matches!(self, Self::LegacyTimestamp(_))
|
||||
}
|
||||
}
|
||||
|
||||
type StepInputs = BTreeMap<String, InputFingerprint>;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StepMetadata {
|
||||
last_run: f64,
|
||||
inputs: BTreeMap<String, f64>,
|
||||
inputs: StepInputs,
|
||||
}
|
||||
|
||||
type Metadata = BTreeMap<String, StepMetadata>;
|
||||
@@ -103,20 +145,14 @@ impl AppDevServer {
|
||||
)
|
||||
.await?;
|
||||
|
||||
if env_truthy("FLUXER_APP_SKIP_I18N_COMPILE") {
|
||||
eprintln!("Skipping pnpm lingui:compile because FLUXER_APP_SKIP_I18N_COMPILE is set.");
|
||||
} else {
|
||||
self.run_cached_step(
|
||||
"lingui",
|
||||
gather_lingui_inputs,
|
||||
"pnpm lingui:compile",
|
||||
|server, shutdown| {
|
||||
Box::pin(server.run_command("pnpm", &["lingui:compile"], shutdown))
|
||||
},
|
||||
&mut shutdown_rx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
self.run_cached_step(
|
||||
"lingui",
|
||||
gather_lingui_inputs,
|
||||
"pnpm lingui:compile",
|
||||
|server, shutdown| Box::pin(server.run_command("pnpm", &["lingui:compile"], shutdown)),
|
||||
&mut shutdown_rx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if *shutdown_rx.borrow() {
|
||||
return Ok(());
|
||||
@@ -175,35 +211,50 @@ impl AppDevServer {
|
||||
shutdown: &mut watch::Receiver<bool>,
|
||||
) -> Result<()>
|
||||
where
|
||||
G: Fn(&Path) -> Result<BTreeMap<String, f64>>,
|
||||
G: Fn(&Path) -> Result<StepInputs>,
|
||||
E: for<'a> FnOnce(
|
||||
&'a AppDevServer,
|
||||
&'a mut watch::Receiver<bool>,
|
||||
)
|
||||
-> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + 'a>>,
|
||||
{
|
||||
if *shutdown.borrow() {
|
||||
return Ok(());
|
||||
}
|
||||
let inputs = gather_inputs(&self.project_root)?;
|
||||
if !self.should_run_step(step_name, &inputs) {
|
||||
let needs_upgrade = self
|
||||
.metadata
|
||||
.get(step_name)
|
||||
.is_some_and(|entry| entry.inputs.values().any(InputFingerprint::is_legacy));
|
||||
if needs_upgrade {
|
||||
self.metadata
|
||||
.insert(step_name.to_string(), StepMetadata { inputs });
|
||||
self.save_metadata()?;
|
||||
}
|
||||
println!("Skipping {label} (no changes detected)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
execute(self, shutdown).await?;
|
||||
self.metadata.insert(
|
||||
step_name.to_string(),
|
||||
StepMetadata {
|
||||
last_run: timestamp_ms(SystemTime::now())?,
|
||||
inputs,
|
||||
},
|
||||
);
|
||||
if *shutdown.borrow() {
|
||||
return Ok(());
|
||||
}
|
||||
self.metadata
|
||||
.insert(step_name.to_string(), StepMetadata { inputs });
|
||||
self.save_metadata()
|
||||
}
|
||||
|
||||
fn should_run_step(&self, step_name: &str, inputs: &BTreeMap<String, f64>) -> bool {
|
||||
fn should_run_step(&self, step_name: &str, inputs: &StepInputs) -> bool {
|
||||
let Some(entry) = self.metadata.get(step_name) else {
|
||||
return true;
|
||||
};
|
||||
&entry.inputs != inputs
|
||||
entry.inputs.len() != inputs.len()
|
||||
|| entry.inputs.iter().any(|(path, cached)| {
|
||||
inputs
|
||||
.get(path)
|
||||
.is_none_or(|current| !cached.matches(current))
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_command(
|
||||
@@ -286,7 +337,7 @@ impl AppDevServer {
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_file_stats(project_root: &Path, paths: &[PathBuf]) -> Result<BTreeMap<String, f64>> {
|
||||
fn collect_file_digests(project_root: &Path, paths: &[PathBuf]) -> Result<StepInputs> {
|
||||
let mut result = BTreeMap::new();
|
||||
for rel_path in paths {
|
||||
let absolute_path = project_root.join(rel_path);
|
||||
@@ -297,16 +348,16 @@ fn collect_file_stats(project_root: &Path, paths: &[PathBuf]) -> Result<BTreeMap
|
||||
"Expected {} to be a file when collecting dev server cache inputs.",
|
||||
rel_path.display()
|
||||
);
|
||||
result.insert(rel_path_key(rel_path), timestamp_ms(metadata.modified()?)?);
|
||||
result.insert(rel_path_key(rel_path), fingerprint_file(&absolute_path)?);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn collect_directory_stats<P>(
|
||||
fn collect_directory_digests<P>(
|
||||
project_root: &Path,
|
||||
root_rel: &Path,
|
||||
predicate: P,
|
||||
) -> Result<BTreeMap<String, f64>>
|
||||
) -> Result<StepInputs>
|
||||
where
|
||||
P: Fn(&str) -> bool,
|
||||
{
|
||||
@@ -335,20 +386,47 @@ where
|
||||
if !predicate(&key) {
|
||||
continue;
|
||||
}
|
||||
result.insert(key, timestamp_ms(entry.metadata()?.modified()?)?);
|
||||
result.insert(key, fingerprint_file(entry.path())?);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn hash_file(path: &Path) -> Result<String> {
|
||||
let mut file = fs::File::open(path)
|
||||
.with_context(|| format!("Failed to open {} for hashing", path.display()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
let bytes_read = file
|
||||
.read(&mut buffer)
|
||||
.with_context(|| format!("Failed to read {} for hashing", path.display()))?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..bytes_read]);
|
||||
}
|
||||
Ok(hex::encode(hasher.finalize()))
|
||||
}
|
||||
|
||||
fn fingerprint_file(path: &Path) -> Result<InputFingerprint> {
|
||||
let sha256 = hash_file(path)?;
|
||||
let metadata = fs::metadata(path)
|
||||
.with_context(|| format!("Failed to stat {} after hashing", path.display()))?;
|
||||
Ok(InputFingerprint::Sha256 {
|
||||
sha256,
|
||||
legacy_timestamp_ms: Some(timestamp_ms(metadata.modified()?)?),
|
||||
})
|
||||
}
|
||||
|
||||
fn should_walk_entry(path: &Path, skip_dirs: &BTreeSet<&str>) -> bool {
|
||||
path.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.is_none_or(|name| !skip_dirs.contains(name))
|
||||
}
|
||||
|
||||
fn gather_wasm_inputs(project_root: &Path) -> Result<BTreeMap<String, f64>> {
|
||||
fn gather_wasm_inputs(project_root: &Path) -> Result<StepInputs> {
|
||||
let markdown_parser_rust_dir = PathBuf::from("../packages/markdown_parser/rust");
|
||||
let mut inputs = collect_file_stats(
|
||||
let mut inputs = collect_file_digests(
|
||||
project_root,
|
||||
&[
|
||||
PathBuf::from("../tools/ci/Cargo.toml"),
|
||||
@@ -361,12 +439,12 @@ fn gather_wasm_inputs(project_root: &Path) -> Result<BTreeMap<String, f64>> {
|
||||
markdown_parser_rust_dir.join("Cargo.toml"),
|
||||
],
|
||||
)?;
|
||||
inputs.extend(collect_directory_stats(
|
||||
inputs.extend(collect_directory_digests(
|
||||
project_root,
|
||||
Path::new("rust/libfluxcore"),
|
||||
|path| !path.contains("/target/"),
|
||||
)?);
|
||||
inputs.extend(collect_directory_stats(
|
||||
inputs.extend(collect_directory_digests(
|
||||
project_root,
|
||||
&markdown_parser_rust_dir,
|
||||
|path| !path.contains("/target/"),
|
||||
@@ -374,15 +452,15 @@ fn gather_wasm_inputs(project_root: &Path) -> Result<BTreeMap<String, f64>> {
|
||||
Ok(inputs)
|
||||
}
|
||||
|
||||
fn gather_color_inputs(project_root: &Path) -> Result<BTreeMap<String, f64>> {
|
||||
collect_file_stats(
|
||||
fn gather_color_inputs(project_root: &Path) -> Result<StepInputs> {
|
||||
collect_file_digests(
|
||||
project_root,
|
||||
&[PathBuf::from("scripts/GenerateColorSystem.ts")],
|
||||
)
|
||||
}
|
||||
|
||||
fn gather_message_layout_inputs(project_root: &Path) -> Result<BTreeMap<String, f64>> {
|
||||
collect_file_stats(
|
||||
fn gather_message_layout_inputs(project_root: &Path) -> Result<StepInputs> {
|
||||
collect_file_digests(
|
||||
project_root,
|
||||
&[
|
||||
PathBuf::from("scripts/GenerateMessageLayoutCss.ts"),
|
||||
@@ -391,8 +469,8 @@ fn gather_message_layout_inputs(project_root: &Path) -> Result<BTreeMap<String,
|
||||
)
|
||||
}
|
||||
|
||||
fn gather_mask_inputs(project_root: &Path) -> Result<BTreeMap<String, f64>> {
|
||||
collect_file_stats(
|
||||
fn gather_mask_inputs(project_root: &Path) -> Result<StepInputs> {
|
||||
collect_file_digests(
|
||||
project_root,
|
||||
&[
|
||||
PathBuf::from("scripts/GenerateAvatarMasks.ts"),
|
||||
@@ -401,14 +479,14 @@ fn gather_mask_inputs(project_root: &Path) -> Result<BTreeMap<String, f64>> {
|
||||
)
|
||||
}
|
||||
|
||||
fn gather_css_module_inputs(project_root: &Path) -> Result<BTreeMap<String, f64>> {
|
||||
collect_directory_stats(project_root, Path::new("src"), |path| {
|
||||
fn gather_css_module_inputs(project_root: &Path) -> Result<StepInputs> {
|
||||
collect_directory_digests(project_root, Path::new("src"), |path| {
|
||||
path.ends_with(".module.css")
|
||||
})
|
||||
}
|
||||
|
||||
fn gather_lingui_inputs(project_root: &Path) -> Result<BTreeMap<String, f64>> {
|
||||
collect_directory_stats(
|
||||
fn gather_lingui_inputs(project_root: &Path) -> Result<StepInputs> {
|
||||
collect_directory_digests(
|
||||
project_root,
|
||||
Path::new("src/features/i18n/locales"),
|
||||
|path| path.ends_with(".po"),
|
||||
@@ -493,12 +571,6 @@ fn timestamp_ms(timestamp: SystemTime) -> Result<f64> {
|
||||
* 1000.0)
|
||||
}
|
||||
|
||||
fn env_truthy(name: &str) -> bool {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true"))
|
||||
}
|
||||
|
||||
fn display_command(command: &str, args: &[&str]) -> String {
|
||||
std::iter::once(command.to_string())
|
||||
.chain(args.iter().map(|arg| quote_arg(arg)))
|
||||
|
||||
@@ -9194,92 +9194,6 @@
|
||||
],
|
||||
"primary_key": "((guild_id), webhook_id)",
|
||||
"options": ""
|
||||
},
|
||||
{
|
||||
"name": "gateway_hotpatch_events_by_build",
|
||||
"columns": [
|
||||
{
|
||||
"name": "build_sha",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"name": "event_id",
|
||||
"type": "timeuuid"
|
||||
},
|
||||
{
|
||||
"name": "schema_version",
|
||||
"type": "int"
|
||||
},
|
||||
{
|
||||
"name": "kind",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"type": "timestamp"
|
||||
},
|
||||
{
|
||||
"name": "created_by",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"name": "signer_key_id",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"name": "bundle_sha256",
|
||||
"type": "blob"
|
||||
},
|
||||
{
|
||||
"name": "signature",
|
||||
"type": "blob"
|
||||
},
|
||||
{
|
||||
"name": "bundle",
|
||||
"type": "blob"
|
||||
}
|
||||
],
|
||||
"primary_key": "((build_sha), event_id)",
|
||||
"options": "CLUSTERING ORDER BY (event_id ASC)"
|
||||
},
|
||||
{
|
||||
"name": "gateway_hotpatch_applied_by_node",
|
||||
"columns": [
|
||||
{
|
||||
"name": "build_sha",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"name": "node_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"name": "event_id",
|
||||
"type": "timeuuid"
|
||||
},
|
||||
{
|
||||
"name": "applied_at",
|
||||
"type": "timestamp"
|
||||
},
|
||||
{
|
||||
"name": "module_count",
|
||||
"type": "int"
|
||||
},
|
||||
{
|
||||
"name": "bundle_sha256",
|
||||
"type": "blob"
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"name": "error",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"primary_key": "((build_sha, node_name), event_id)",
|
||||
"options": "CLUSTERING ORDER BY (event_id ASC)"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::desktop::install_desktop;
|
||||
use crate::gateway::setup_gateway_config;
|
||||
use crate::object_store::{bootstrap_schema_and_object_store, wait_s3_api};
|
||||
use crate::object_store::bootstrap_schema;
|
||||
use crate::paths::{ensure_state_dirs, ensure_writable_dev_paths};
|
||||
use crate::proc::{PNPM_INSTALL_ENV, RunOptions, run_command, wait_http, wait_tcp};
|
||||
use crate::proc::{PNPM_INSTALL_ENV, RunOptions, run_command, wait_http_success, wait_tcp};
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn bootstrap(skip_install: bool, skip_desktop_install: bool) -> Result<()> {
|
||||
pub async fn bootstrap(skip_install: bool) -> Result<()> {
|
||||
ensure_state_dirs()?;
|
||||
ensure_writable_dev_paths()?;
|
||||
if !skip_install {
|
||||
crate::proc::run(&["corepack", "enable"])?;
|
||||
crate::proc::run(&["corepack", "prepare", "pnpm@10.29.3", "--activate"])?;
|
||||
run_command(
|
||||
&["pnpm", "install", "--frozen-lockfile"],
|
||||
RunOptions {
|
||||
@@ -23,13 +20,10 @@ pub async fn bootstrap(skip_install: bool, skip_desktop_install: bool) -> Result
|
||||
..RunOptions::default()
|
||||
},
|
||||
)?;
|
||||
if !skip_desktop_install {
|
||||
install_desktop()?;
|
||||
}
|
||||
}
|
||||
setup_gateway_config()?;
|
||||
wait_core_infra().await?;
|
||||
bootstrap_schema_and_object_store().await?;
|
||||
bootstrap_schema().await?;
|
||||
println!("Fluxer dev bootstrap complete.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -43,16 +37,11 @@ pub async fn post_start() -> Result<()> {
|
||||
}
|
||||
|
||||
pub async fn wait_core_infra() -> Result<()> {
|
||||
wait_tcp("Postgres", "postgres", 5432, 120).await?;
|
||||
wait_http_success("Meilisearch", "http://meilisearch:7700/health", 120).await?;
|
||||
wait_tcp("Valkey", "valkey", 6379, 120).await?;
|
||||
wait_tcp("NATS", "nats", 4222, 120).await?;
|
||||
wait_tcp("LiveKit", "livekit", 7880, 120).await?;
|
||||
crate::media_proxy::ensure_dev_object_store(true, 120).await?;
|
||||
wait_tcp("SeaweedFS S3", "127.0.0.1", 8333, 120).await?;
|
||||
wait_http(
|
||||
"SeaweedFS master",
|
||||
"http://127.0.0.1:9333/cluster/status",
|
||||
120,
|
||||
)
|
||||
.await?;
|
||||
wait_s3_api(120).await
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+266
-117
@@ -1,15 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::gateway::setup_gateway_config;
|
||||
use crate::gateway::{build_gateway_cluster_nodes, setup_gateway_config};
|
||||
use crate::manifest::{
|
||||
ADMIN_PORT, ANY_HOST, API_PORT, APP_PORT, APP_PROXY_PORT, DEV_PROXY_PORT, LOOPBACK_HOST,
|
||||
MEDIA_PROXY_PORT, rust_services,
|
||||
ADMIN_PORT, ANY_HOST, API_PORT, APP_PORT, APP_PROXY_PORT, DEV_PROXY_GATEWAY_PORTS_ENV,
|
||||
DEV_PROXY_PORT, GATEWAY_PORT, LOOPBACK_HOST, MEDIA_PROXY_PORT, rust_services,
|
||||
};
|
||||
use crate::object_store::s3_endpoint;
|
||||
use crate::paths::{DESKTOP_DIR, ROOT};
|
||||
use crate::proc::{
|
||||
PNPM_INSTALL_ENV, RESTART_LIMIT, RESTART_WINDOW, RunOptions, ShutdownSignal, format_command,
|
||||
merged_env, restart_budget_exceeded, run_command, wait_http,
|
||||
AwaitOutcome, PNPM_INSTALL_ENV, RESTART_LIMIT, RESTART_WINDOW, RunOptions, ShutdownSignal,
|
||||
await_or_shutdown, configure_process_group, format_command, merged_env, prefix_output,
|
||||
restart_budget_exceeded, run_command_interruptible, wait_http,
|
||||
};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
@@ -30,10 +31,10 @@ const DEFAULT_TASKS: &[&str] = &[
|
||||
"proxy",
|
||||
"services",
|
||||
"media",
|
||||
"gateway",
|
||||
"marketing",
|
||||
"admin",
|
||||
"api",
|
||||
"gateway-single",
|
||||
"worker",
|
||||
"app",
|
||||
"app-proxy",
|
||||
@@ -63,7 +64,7 @@ pub async fn run_dev(task_names: &[String], cloudflare_tunnel: bool) -> Result<i
|
||||
"The private marketing project is unavailable. Authorized maintainers can initialize it with:\n {MARKETING_INITIALIZE_COMMAND}"
|
||||
);
|
||||
}
|
||||
let tasks = task_table_for_availability(marketing_availability)?;
|
||||
let mut tasks = task_table_for_availability(marketing_availability)?;
|
||||
let selected = if task_names.is_empty() {
|
||||
DEFAULT_TASKS
|
||||
.iter()
|
||||
@@ -88,11 +89,30 @@ pub async fn run_dev(task_names: &[String], cloudflare_tunnel: bool) -> Result<i
|
||||
unknown.join(", ")
|
||||
);
|
||||
}
|
||||
ensure_js_dependencies_if_needed(&selected)?;
|
||||
ensure_object_store_if_needed(&selected).await?;
|
||||
wait_for_search_backend_if_needed(&selected).await?;
|
||||
setup_gateway_config()?;
|
||||
let gateway_readiness_port = configure_gateway_proxy_ports(&mut tasks, &selected)?;
|
||||
let mut shutdown = ShutdownSignal::new()?;
|
||||
match ensure_js_dependencies_if_needed(&selected, &mut shutdown).await? {
|
||||
AwaitOutcome::Completed(()) => {}
|
||||
AwaitOutcome::Shutdown(signal) => {
|
||||
println!("Received {signal}; stopping dev startup...");
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
match await_or_shutdown(&mut shutdown, ensure_object_store_if_needed(&selected)).await {
|
||||
AwaitOutcome::Completed(result) => result?,
|
||||
AwaitOutcome::Shutdown(signal) => {
|
||||
println!("Received {signal}; stopping dev startup...");
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
match await_or_shutdown(&mut shutdown, wait_for_search_backend_if_needed(&selected)).await {
|
||||
AwaitOutcome::Completed(result) => result?,
|
||||
AwaitOutcome::Shutdown(signal) => {
|
||||
println!("Received {signal}; stopping dev startup...");
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
setup_gateway_config()?;
|
||||
let mut processes = Vec::new();
|
||||
let mut task_names = Vec::new();
|
||||
let mut task_specs = Vec::new();
|
||||
@@ -102,29 +122,82 @@ pub async fn run_dev(task_names: &[String], cloudflare_tunnel: bool) -> Result<i
|
||||
let mut next_object_store_check = Instant::now() + object_store_monitor_interval;
|
||||
let selected_for_readiness = selected.clone();
|
||||
for name in selected {
|
||||
if name == CLOUDFLARE_TUNNEL_TASK
|
||||
&& let Err(error) =
|
||||
wait_for_cloudflare_tunnel_routes(&mut processes, &selected_for_readiness).await
|
||||
{
|
||||
crate::gateway::stop_processes(&mut processes);
|
||||
return Err(error);
|
||||
if name == CLOUDFLARE_TUNNEL_TASK {
|
||||
match await_or_shutdown(
|
||||
&mut shutdown,
|
||||
wait_for_cloudflare_tunnel_routes(&mut processes, &selected_for_readiness),
|
||||
)
|
||||
.await
|
||||
{
|
||||
AwaitOutcome::Completed(Ok(())) => {}
|
||||
AwaitOutcome::Completed(Err(error)) => {
|
||||
crate::gateway::stop_processes(&mut processes);
|
||||
return Err(error);
|
||||
}
|
||||
AwaitOutcome::Shutdown(signal) => {
|
||||
println!("Received {signal}; stopping dev tasks...");
|
||||
crate::gateway::stop_processes(&mut processes);
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let process = start_task(tasks.get(name.as_str()).expect("validated task"))?;
|
||||
let process = match start_task(tasks.get(name.as_str()).expect("validated task")) {
|
||||
Ok(process) => process,
|
||||
Err(error) => {
|
||||
crate::gateway::stop_processes(&mut processes);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
processes.push(process);
|
||||
task_names.push(name.clone());
|
||||
task_specs.push(tasks.get(name.as_str()).expect("validated task").clone());
|
||||
task_restarts.push(VecDeque::new());
|
||||
if name == "services"
|
||||
&& let Err(error) = wait_for_rust_services(&mut processes).await
|
||||
{
|
||||
crate::gateway::stop_processes(&mut processes);
|
||||
return Err(error);
|
||||
if name == "services" {
|
||||
match await_or_shutdown(&mut shutdown, wait_for_rust_services(&mut processes)).await {
|
||||
AwaitOutcome::Completed(Ok(())) => {}
|
||||
AwaitOutcome::Completed(Err(error)) => {
|
||||
crate::gateway::stop_processes(&mut processes);
|
||||
return Err(error);
|
||||
}
|
||||
AwaitOutcome::Shutdown(signal) => {
|
||||
println!("Received {signal}; stopping dev tasks...");
|
||||
crate::gateway::stop_processes(&mut processes);
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if name == "api"
|
||||
&& let Err(error) = wait_for_api(&mut processes).await
|
||||
{
|
||||
crate::gateway::stop_processes(&mut processes);
|
||||
return Err(error);
|
||||
if name == "gateway" || name == "gateway-single" {
|
||||
match await_or_shutdown(
|
||||
&mut shutdown,
|
||||
wait_for_gateway(&mut processes, gateway_readiness_port),
|
||||
)
|
||||
.await
|
||||
{
|
||||
AwaitOutcome::Completed(Ok(())) => {}
|
||||
AwaitOutcome::Completed(Err(error)) => {
|
||||
crate::gateway::stop_processes(&mut processes);
|
||||
return Err(error);
|
||||
}
|
||||
AwaitOutcome::Shutdown(signal) => {
|
||||
println!("Received {signal}; stopping dev tasks...");
|
||||
crate::gateway::stop_processes(&mut processes);
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if name == "api" {
|
||||
match await_or_shutdown(&mut shutdown, wait_for_api(&mut processes)).await {
|
||||
AwaitOutcome::Completed(Ok(())) => {}
|
||||
AwaitOutcome::Completed(Err(error)) => {
|
||||
crate::gateway::stop_processes(&mut processes);
|
||||
return Err(error);
|
||||
}
|
||||
AwaitOutcome::Shutdown(signal) => {
|
||||
println!("Received {signal}; stopping dev tasks...");
|
||||
crate::gateway::stop_processes(&mut processes);
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
loop {
|
||||
@@ -135,9 +208,17 @@ pub async fn run_dev(task_names: &[String], cloudflare_tunnel: bool) -> Result<i
|
||||
return Err(error);
|
||||
}
|
||||
if monitor_object_store && Instant::now() >= next_object_store_check {
|
||||
if let Err(error) = monitor_object_store_dependency().await {
|
||||
crate::gateway::stop_processes(&mut processes);
|
||||
return Err(error);
|
||||
match await_or_shutdown(&mut shutdown, monitor_object_store_dependency()).await {
|
||||
AwaitOutcome::Completed(Ok(())) => {}
|
||||
AwaitOutcome::Completed(Err(error)) => {
|
||||
crate::gateway::stop_processes(&mut processes);
|
||||
return Err(error);
|
||||
}
|
||||
AwaitOutcome::Shutdown(signal) => {
|
||||
println!("Received {signal}; stopping dev tasks...");
|
||||
crate::gateway::stop_processes(&mut processes);
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
next_object_store_check = Instant::now() + object_store_monitor_interval;
|
||||
}
|
||||
@@ -152,6 +233,38 @@ pub async fn run_dev(task_names: &[String], cloudflare_tunnel: bool) -> Result<i
|
||||
}
|
||||
}
|
||||
|
||||
fn configure_gateway_proxy_ports(
|
||||
tasks: &mut BTreeMap<&'static str, DevTask>,
|
||||
selected: &[String],
|
||||
) -> Result<u16> {
|
||||
let cluster_selected = selected.iter().any(|name| name == "gateway");
|
||||
let single_selected = selected.iter().any(|name| name == "gateway-single");
|
||||
if cluster_selected && single_selected {
|
||||
bail!("Select either `gateway` or `gateway-single`, not both");
|
||||
}
|
||||
let ports = if cluster_selected {
|
||||
build_gateway_cluster_nodes()?
|
||||
.into_iter()
|
||||
.filter(|node| node.role == "websocket")
|
||||
.map(|node| node.http_port)
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
vec![GATEWAY_PORT]
|
||||
};
|
||||
let value = ports
|
||||
.iter()
|
||||
.map(u16::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let proxy = tasks
|
||||
.get_mut("proxy")
|
||||
.context("dev task table is missing the proxy task")?;
|
||||
proxy
|
||||
.env
|
||||
.push((DEV_PROXY_GATEWAY_PORTS_ENV.to_owned(), Some(value)));
|
||||
Ok(*ports.first().expect("gateway proxy ports are non-empty"))
|
||||
}
|
||||
|
||||
async fn ensure_object_store_if_needed(selected: &[String]) -> Result<()> {
|
||||
if !selected_needs_object_store(selected) {
|
||||
return Ok(());
|
||||
@@ -202,6 +315,7 @@ fn restart_exited_tasks(
|
||||
let Some(status) = process.try_wait()? else {
|
||||
continue;
|
||||
};
|
||||
crate::gateway::stop_child_processes(&mut [process]);
|
||||
if restart_budget_exceeded(&mut task_restarts[index], Instant::now()) {
|
||||
bail!(
|
||||
"Dev task {} exited with {status} after {RESTART_LIMIT} restarts within {}s; giving up",
|
||||
@@ -237,6 +351,10 @@ fn task_table_for_availability(
|
||||
marketing_availability: MarketingAvailability,
|
||||
) -> Result<BTreeMap<&'static str, DevTask>> {
|
||||
let self_tool = self_tool_command()?;
|
||||
let api_dir = ROOT.join("fluxer_api");
|
||||
let api_tsx = api_dir.join("node_modules/.bin/tsx");
|
||||
let app_dir = ROOT.join("fluxer_app");
|
||||
let app_dev_server = ROOT.join("tools/ci/run.sh");
|
||||
let public_url = public_url();
|
||||
let marketing_endpoint = std::env::var("FLUXER_MARKETING_ENDPOINT")
|
||||
.unwrap_or_else(|_| format!("{public_url}/marketing"));
|
||||
@@ -260,8 +378,13 @@ fn task_table_for_availability(
|
||||
});
|
||||
insert(DevTask {
|
||||
name: "api",
|
||||
args: strings(&["pnpm", "--filter", "fluxer_api", "dev"]),
|
||||
cwd: ROOT.clone(),
|
||||
args: vec![
|
||||
api_tsx.display().to_string(),
|
||||
"watch".to_owned(),
|
||||
"--clear-screen=false".to_owned(),
|
||||
"src/AppEntrypoint.ts".to_owned(),
|
||||
],
|
||||
cwd: api_dir.clone(),
|
||||
env: vec![
|
||||
(
|
||||
"FLUXER_S3_PUBLIC_ENDPOINT".to_owned(),
|
||||
@@ -286,29 +409,26 @@ fn task_table_for_availability(
|
||||
});
|
||||
insert(DevTask {
|
||||
name: "worker",
|
||||
args: strings(&[
|
||||
"pnpm",
|
||||
"--filter",
|
||||
"fluxer_api",
|
||||
"exec",
|
||||
"tsx",
|
||||
"watch",
|
||||
"--clear-screen=false",
|
||||
"src/WorkerEntrypoint.ts",
|
||||
]),
|
||||
cwd: ROOT.clone(),
|
||||
args: vec![
|
||||
api_tsx.display().to_string(),
|
||||
"watch".to_owned(),
|
||||
"--clear-screen=false".to_owned(),
|
||||
"src/WorkerEntrypoint.ts".to_owned(),
|
||||
],
|
||||
cwd: api_dir,
|
||||
env: Vec::new(),
|
||||
});
|
||||
insert(DevTask {
|
||||
name: "app",
|
||||
args: strings(&["pnpm", "--filter", "fluxer_app", "dev"]),
|
||||
cwd: ROOT.clone(),
|
||||
args: vec![
|
||||
app_dev_server.display().to_string(),
|
||||
"app-dev-server".to_owned(),
|
||||
],
|
||||
cwd: app_dir,
|
||||
env: vec![
|
||||
("TOKIO_WORKER_THREADS".to_owned(), Some("4".to_owned())),
|
||||
("RAYON_NUM_THREADS".to_owned(), Some("4".to_owned())),
|
||||
("FLUXER_APP_DEV_PORT".to_owned(), Some(APP_PORT.to_string())),
|
||||
(
|
||||
"FLUXER_APP_SKIP_I18N_COMPILE".to_owned(),
|
||||
Some("true".to_owned()),
|
||||
),
|
||||
(
|
||||
"FLUXER_STATIC_CDN_ENDPOINT".to_owned(),
|
||||
Some(public_url.clone()),
|
||||
@@ -584,27 +704,39 @@ async fn wait_for_search_backend_if_needed(selected: &[String]) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
let env = merged_env(None, true)?;
|
||||
let backend = SearchBackend::from_env(&env)?;
|
||||
let search_url = env
|
||||
.get("FLUXER_SEARCH_URL")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| default_search_url(&env));
|
||||
wait_http(search_backend_label(&env), &search_url, 120).await
|
||||
.filter(|url| !url.trim().is_empty())
|
||||
.context("Missing FLUXER_SEARCH_URL for the selected search backend")?;
|
||||
wait_http(backend.label(), search_url, 120).await
|
||||
}
|
||||
|
||||
fn default_search_url(env: &BTreeMap<String, String>) -> String {
|
||||
if env
|
||||
.get("FLUXER_SEARCH_ENGINE")
|
||||
.is_some_and(|engine| engine == "meilisearch")
|
||||
{
|
||||
return "http://127.0.0.1:7700".to_owned();
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SearchBackend {
|
||||
Elasticsearch,
|
||||
Meilisearch,
|
||||
}
|
||||
|
||||
impl SearchBackend {
|
||||
fn from_env(env: &BTreeMap<String, String>) -> Result<Self> {
|
||||
match env.get("FLUXER_SEARCH_ENGINE").map(String::as_str) {
|
||||
Some("elasticsearch") => Ok(Self::Elasticsearch),
|
||||
Some("meilisearch") => Ok(Self::Meilisearch),
|
||||
Some(engine) => bail!(
|
||||
"Unsupported FLUXER_SEARCH_ENGINE value {engine:?}; expected `meilisearch` or `elasticsearch`"
|
||||
),
|
||||
None => {
|
||||
bail!("Missing FLUXER_SEARCH_ENGINE; expected `meilisearch` or `elasticsearch`")
|
||||
}
|
||||
}
|
||||
}
|
||||
"http://127.0.0.1:9200".to_owned()
|
||||
}
|
||||
|
||||
fn search_backend_label(env: &BTreeMap<String, String>) -> &'static str {
|
||||
match env.get("FLUXER_SEARCH_ENGINE").map(String::as_str) {
|
||||
Some("meilisearch") => "Meilisearch",
|
||||
_ => "Elasticsearch",
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Elasticsearch => "Elasticsearch",
|
||||
Self::Meilisearch => "Meilisearch",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -627,9 +759,12 @@ fn public_url() -> String {
|
||||
.unwrap_or_else(|_| format!("http://localhost:{DEV_PROXY_PORT}"))
|
||||
}
|
||||
|
||||
fn ensure_js_dependencies_if_needed(selected: &[String]) -> Result<()> {
|
||||
async fn ensure_js_dependencies_if_needed(
|
||||
selected: &[String],
|
||||
shutdown: &mut ShutdownSignal,
|
||||
) -> Result<AwaitOutcome<()>> {
|
||||
if selected_needs_js_dependency_preflight(selected) {
|
||||
run_command(
|
||||
match run_command_interruptible(
|
||||
&["pnpm", "install", "--frozen-lockfile"],
|
||||
RunOptions {
|
||||
env: PNPM_INSTALL_ENV
|
||||
@@ -638,11 +773,17 @@ fn ensure_js_dependencies_if_needed(selected: &[String]) -> Result<()> {
|
||||
.collect(),
|
||||
..RunOptions::default()
|
||||
},
|
||||
)?;
|
||||
shutdown,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
AwaitOutcome::Completed(_) => {}
|
||||
AwaitOutcome::Shutdown(signal) => return Ok(AwaitOutcome::Shutdown(signal)),
|
||||
}
|
||||
}
|
||||
if selected.iter().any(|name| name == "marketing") {
|
||||
let marketing_dir = ROOT.join("fluxer_marketing");
|
||||
run_command(
|
||||
match run_command_interruptible(
|
||||
&["pnpm", "install", "--frozen-lockfile"],
|
||||
RunOptions {
|
||||
cwd: &marketing_dir,
|
||||
@@ -652,9 +793,15 @@ fn ensure_js_dependencies_if_needed(selected: &[String]) -> Result<()> {
|
||||
.collect(),
|
||||
..RunOptions::default()
|
||||
},
|
||||
)?;
|
||||
shutdown,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
AwaitOutcome::Completed(_) => {}
|
||||
AwaitOutcome::Shutdown(signal) => return Ok(AwaitOutcome::Shutdown(signal)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(AwaitOutcome::Completed(()))
|
||||
}
|
||||
|
||||
fn selected_needs_js_dependency_preflight(selected: &[String]) -> bool {
|
||||
@@ -689,14 +836,7 @@ fn start_task(task: &DevTask) -> Result<Child> {
|
||||
.envs(env)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
use std::os::unix::process::CommandExt;
|
||||
command.pre_exec(|| {
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
configure_process_group(&mut command);
|
||||
let mut child = command.spawn()?;
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let label = task.name.to_owned();
|
||||
@@ -718,13 +858,16 @@ async fn wait_for_rust_services(processes: &mut [Child]) -> Result<()> {
|
||||
.len()
|
||||
.checked_sub(1)
|
||||
.expect("services process was just pushed");
|
||||
let deadline = Instant::now() + Duration::from_secs(timeout);
|
||||
for spec in rust_services() {
|
||||
for (mode, port) in [("router", spec.port_base), ("shard", spec.port_base + 1)] {
|
||||
check_startup_processes(processes, services_index, spec.name, mode)?;
|
||||
wait_http(
|
||||
&format!("Rust service {}:{mode}", spec.name),
|
||||
let name = format!("Rust service {}:{mode}", spec.name);
|
||||
wait_http_with_startup_checks_until(
|
||||
processes,
|
||||
services_index,
|
||||
&name,
|
||||
&format!("http://{LOOPBACK_HOST}:{port}/_health"),
|
||||
timeout,
|
||||
deadline,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -732,6 +875,25 @@ async fn wait_for_rust_services(processes: &mut [Child]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_gateway(processes: &mut [Child], port: u16) -> Result<()> {
|
||||
let timeout = std::env::var("FLUXER_DEV_GATEWAY_READY_TIMEOUT")
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(180);
|
||||
let gateway_index = processes
|
||||
.len()
|
||||
.checked_sub(1)
|
||||
.expect("gateway process was just pushed");
|
||||
wait_http_with_startup_checks(
|
||||
processes,
|
||||
gateway_index,
|
||||
"Gateway",
|
||||
&format!("http://{LOOPBACK_HOST}:{port}/_health/ready"),
|
||||
timeout,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn wait_for_api(processes: &mut [Child]) -> Result<()> {
|
||||
let timeout = std::env::var("FLUXER_DEV_API_READY_TIMEOUT")
|
||||
.ok()
|
||||
@@ -827,7 +989,7 @@ async fn wait_http_for_dev_tasks(
|
||||
while Instant::now() < deadline {
|
||||
check_dev_startup_processes(processes, name)?;
|
||||
match client.get(url).send().await {
|
||||
Ok(response) if response.status().as_u16() < 500 => {
|
||||
Ok(response) if response.status().is_success() => {
|
||||
println!("{name} is reachable at {url}");
|
||||
return Ok(());
|
||||
}
|
||||
@@ -849,16 +1011,32 @@ async fn wait_http_with_startup_checks(
|
||||
name: &str,
|
||||
url: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Result<()> {
|
||||
wait_http_with_startup_checks_until(
|
||||
processes,
|
||||
watched_index,
|
||||
name,
|
||||
url,
|
||||
Instant::now() + Duration::from_secs(timeout_secs),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn wait_http_with_startup_checks_until(
|
||||
processes: &mut [Child],
|
||||
watched_index: usize,
|
||||
name: &str,
|
||||
url: &str,
|
||||
deadline: Instant,
|
||||
) -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()?;
|
||||
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
|
||||
let mut last_error = None;
|
||||
while Instant::now() < deadline {
|
||||
check_startup_task_processes(processes, watched_index, name)?;
|
||||
match client.get(url).send().await {
|
||||
Ok(response) if response.status().as_u16() < 500 => {
|
||||
Ok(response) if response.status().is_success() => {
|
||||
println!("{name} is reachable at {url}");
|
||||
return Ok(());
|
||||
}
|
||||
@@ -901,35 +1079,6 @@ fn check_startup_task_processes(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_startup_processes(
|
||||
processes: &mut [Child],
|
||||
services_index: usize,
|
||||
service_name: &str,
|
||||
mode: &str,
|
||||
) -> Result<()> {
|
||||
for (index, process) in processes.iter_mut().enumerate() {
|
||||
if let Some(status) = process.try_wait()? {
|
||||
let code = status.code().unwrap_or(1);
|
||||
if index == services_index {
|
||||
bail!(
|
||||
"Rust service supervisor exited with status {code} before {service_name}:{mode} became ready"
|
||||
);
|
||||
}
|
||||
bail!(
|
||||
"Dev task exited with status {code} before Rust service {service_name}:{mode} became ready"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prefix_output(label: &str, reader: impl std::io::Read) {
|
||||
use std::io::{BufRead, BufReader};
|
||||
for line in BufReader::new(reader).lines().map_while(|line| line.ok()) {
|
||||
println!("[{label}] {line}");
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _cwd_is_root(path: &Path) -> bool {
|
||||
path == ROOT.as_path()
|
||||
@@ -940,17 +1089,17 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_tasks_keep_legacy_order() {
|
||||
fn default_tasks_use_lightweight_gateway() {
|
||||
assert_eq!(
|
||||
DEFAULT_TASKS,
|
||||
&[
|
||||
"proxy",
|
||||
"services",
|
||||
"media",
|
||||
"gateway",
|
||||
"marketing",
|
||||
"admin",
|
||||
"api",
|
||||
"gateway-single",
|
||||
"worker",
|
||||
"app",
|
||||
"app-proxy"
|
||||
|
||||
+392
-319
@@ -1,18 +1,20 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::gateway_reload::{
|
||||
ArtifactState, changed_artifacts, hot_reload_enabled, hot_reload_modules, snapshot_artifacts,
|
||||
spawn_source_watcher,
|
||||
};
|
||||
use crate::paths::{DEV_GATEWAY_DIR, GATEWAY_CONFIG_DIR, ROOT};
|
||||
use crate::proc::{
|
||||
RESTART_LIMIT, RESTART_WINDOW, RunOptions, ShutdownSignal, format_command, merged_env,
|
||||
restart_budget_exceeded, run_command,
|
||||
AwaitOutcome, RESTART_LIMIT, RESTART_WINDOW, RunOptions, ShutdownSignal, await_or_shutdown,
|
||||
configure_process_group, force_kill_process_group, format_command, merged_env,
|
||||
pending_shutdown, prefix_output, process_group_running, restart_budget_exceeded,
|
||||
run_command_interruptible, stop_process_group, terminate_process_group,
|
||||
};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use sha2::{Digest, Sha256};
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::VecDeque;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::net::{SocketAddr, TcpListener};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
@@ -47,9 +49,14 @@ const GATEWAY_COMPILE_COMMAND: &[&str] = &[
|
||||
"--step",
|
||||
"compile",
|
||||
];
|
||||
const GATEWAY_DEPENDENCY_INPUTS: &[&str] = &["rebar.config", "rebar.config.script", "rebar.lock"];
|
||||
const GATEWAY_DEPENDENCY_INPUT_MAX_BYTES: u64 = 4 * 1024 * 1024;
|
||||
const GATEWAY_DEPENDENCY_STAMP_FILE: &str = "rebar-dependencies.sha256";
|
||||
const GATEWAY_DEPENDENCY_STAMP_MAX_BYTES: u64 = 128;
|
||||
const NODE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const NODE_RESTART_PORT_WAIT: Duration = Duration::from_secs(75);
|
||||
const STOP_GRACE_PERIOD: Duration = Duration::from_secs(10);
|
||||
const NESTED_STOP_GRACE_PERIOD: Duration = Duration::from_secs(5);
|
||||
const ROOT_STOP_GRACE_PERIOD: Duration = Duration::from_secs(15);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GatewayNode {
|
||||
@@ -128,33 +135,42 @@ fn remove_stale_gateway_config() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_gateway() -> Result<()> {
|
||||
setup_gateway_config()?;
|
||||
compile_gateway()?;
|
||||
let command = build_gateway_command(DEV_GATEWAY_DIR.as_path())?;
|
||||
let env = merged_env(None, true)?;
|
||||
println!("$ {}", format_command(&command));
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
let err = Command::new(&command[0])
|
||||
.args(&command[1..])
|
||||
.current_dir(gateway_dir())
|
||||
.env_clear()
|
||||
.envs(env)
|
||||
.exec();
|
||||
bail!("failed to exec gateway: {err}");
|
||||
pub async fn run_gateway() -> Result<i32> {
|
||||
let mut shutdown = ShutdownSignal::new()?;
|
||||
let node_names = std::collections::HashSet::from([env::var("FLUXER_ERLANG_NODE_NAME")
|
||||
.unwrap_or_else(|_| "fluxer_gateway@127.0.0.1".to_owned())]);
|
||||
cleanup_orphaned_gateway_processes(&node_names).await?;
|
||||
if let Some(signal) = pending_shutdown(&mut shutdown).await {
|
||||
println!("Received {signal}; stopping gateway startup...");
|
||||
return Ok(0);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let status = Command::new(&command[0])
|
||||
.args(&command[1..])
|
||||
.current_dir(gateway_dir())
|
||||
.env_clear()
|
||||
.envs(env)
|
||||
.status()?;
|
||||
std::process::exit(status.code().unwrap_or(1));
|
||||
setup_gateway_config()?;
|
||||
match compile_gateway_interruptible(&mut shutdown).await? {
|
||||
AwaitOutcome::Completed(_) => {}
|
||||
AwaitOutcome::Shutdown(signal) => {
|
||||
println!("Received {signal}; stopping gateway startup...");
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
let command = build_gateway_command(DEV_GATEWAY_DIR.as_path())?;
|
||||
let command_refs = command.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let working_dir = gateway_dir();
|
||||
let outcome = run_command_interruptible(
|
||||
&command_refs,
|
||||
RunOptions {
|
||||
cwd: &working_dir,
|
||||
..RunOptions::default()
|
||||
},
|
||||
&mut shutdown,
|
||||
)
|
||||
.await?;
|
||||
stop_idle_epmd();
|
||||
match outcome {
|
||||
AwaitOutcome::Completed(output) => Ok(output.status.code().unwrap_or(1)),
|
||||
AwaitOutcome::Shutdown(signal) => {
|
||||
println!("Received {signal}; stopping gateway...");
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,11 +182,31 @@ struct SupervisedNode {
|
||||
|
||||
pub async fn run_gateway_cluster() -> Result<i32> {
|
||||
let nodes = build_gateway_cluster_nodes()?;
|
||||
cleanup_orphaned_gateway_nodes(&nodes).await?;
|
||||
wait_for_cluster_ports_available(&nodes).await?;
|
||||
setup_gateway_cluster_config(&nodes)?;
|
||||
compile_gateway()?;
|
||||
let mut shutdown = ShutdownSignal::new()?;
|
||||
let node_names = nodes
|
||||
.iter()
|
||||
.map(GatewayNode::erlang_name)
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
cleanup_orphaned_gateway_processes(&node_names).await?;
|
||||
if let Some(signal) = pending_shutdown(&mut shutdown).await {
|
||||
println!("Received {signal}; stopping gateway startup...");
|
||||
return Ok(0);
|
||||
}
|
||||
match await_or_shutdown(&mut shutdown, wait_for_cluster_ports_available(&nodes)).await {
|
||||
AwaitOutcome::Completed(result) => result?,
|
||||
AwaitOutcome::Shutdown(signal) => {
|
||||
println!("Received {signal}; stopping gateway startup...");
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
setup_gateway_cluster_config(&nodes)?;
|
||||
match compile_gateway_interruptible(&mut shutdown).await? {
|
||||
AwaitOutcome::Completed(_) => {}
|
||||
AwaitOutcome::Shutdown(signal) => {
|
||||
println!("Received {signal}; stopping gateway startup...");
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
let static_peers = nodes
|
||||
.iter()
|
||||
.map(GatewayNode::erlang_name)
|
||||
@@ -179,57 +215,35 @@ pub async fn run_gateway_cluster() -> Result<i32> {
|
||||
let mut supervised = Vec::new();
|
||||
print_gateway_cluster_topology(&nodes);
|
||||
for node in &nodes {
|
||||
let child = match start_node(node, &static_peers) {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
stop_supervised(&mut supervised);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
supervised.push(SupervisedNode {
|
||||
node: node.clone(),
|
||||
child: start_node(node, &static_peers)?,
|
||||
child,
|
||||
restarts: VecDeque::new(),
|
||||
});
|
||||
}
|
||||
let watcher = hot_reload_enabled().then(spawn_source_watcher);
|
||||
if watcher.is_some() {
|
||||
println!(
|
||||
"Gateway hot reload enabled; watching fluxer_gateway sources (set FLUXER_DEV_GATEWAY_HOT_RELOAD=false to disable)"
|
||||
);
|
||||
}
|
||||
let mut compile: Option<(tokio::task::JoinHandle<bool>, ArtifactState)> = None;
|
||||
let mut compile_queued = false;
|
||||
loop {
|
||||
if let Err(error) = restart_exited_nodes(&mut supervised, &static_peers).await {
|
||||
stop_supervised(&mut supervised);
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(receiver) = &watcher {
|
||||
while receiver.try_recv().is_ok() {
|
||||
compile_queued = true;
|
||||
}
|
||||
}
|
||||
if compile_queued && compile.is_none() {
|
||||
compile_queued = false;
|
||||
println!("Gateway sources changed; recompiling for hot reload...");
|
||||
let before = snapshot_artifacts();
|
||||
compile = Some((
|
||||
tokio::task::spawn_blocking(compile_gateway_for_reload),
|
||||
before,
|
||||
));
|
||||
}
|
||||
if compile
|
||||
.as_ref()
|
||||
.is_some_and(|(handle, _)| handle.is_finished())
|
||||
match await_or_shutdown(
|
||||
&mut shutdown,
|
||||
restart_exited_nodes(&mut supervised, &static_peers),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let (handle, before) = compile.take().expect("compile task present");
|
||||
match handle.await {
|
||||
Ok(true) => {
|
||||
if let Err(error) =
|
||||
apply_hot_reload(&mut supervised, &before, &static_peers).await
|
||||
{
|
||||
stop_supervised(&mut supervised);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok(false) => println!(
|
||||
"Gateway compile failed; hot reload skipped (fix the errors and save again)"
|
||||
),
|
||||
Err(error) => println!("Gateway compile task failed: {error}"),
|
||||
AwaitOutcome::Completed(Ok(())) => {}
|
||||
AwaitOutcome::Completed(Err(error)) => {
|
||||
stop_supervised(&mut supervised);
|
||||
return Err(error);
|
||||
}
|
||||
AwaitOutcome::Shutdown(signal) => {
|
||||
println!("Received {signal}; stopping gateway cluster...");
|
||||
stop_supervised(&mut supervised);
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
tokio::select! {
|
||||
@@ -243,24 +257,116 @@ pub async fn run_gateway_cluster() -> Result<i32> {
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_gateway() -> Result<()> {
|
||||
run_command(GATEWAY_COMPILE_COMMAND, RunOptions::default()).map(drop)
|
||||
async fn compile_gateway_interruptible(
|
||||
shutdown: &mut ShutdownSignal,
|
||||
) -> Result<AwaitOutcome<std::process::Output>> {
|
||||
let dependency_stamp = prepare_gateway_compile()?;
|
||||
let outcome =
|
||||
run_command_interruptible(GATEWAY_COMPILE_COMMAND, RunOptions::default(), shutdown).await?;
|
||||
if matches!(&outcome, AwaitOutcome::Completed(_)) {
|
||||
write_gateway_dependency_stamp(&dependency_stamp)?;
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
fn compile_gateway_for_reload() -> bool {
|
||||
run_command(
|
||||
GATEWAY_COMPILE_COMMAND,
|
||||
RunOptions {
|
||||
check: false,
|
||||
..RunOptions::default()
|
||||
},
|
||||
)
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
fn prepare_gateway_compile() -> Result<String> {
|
||||
let dependency_stamp = gateway_dependency_stamp()?;
|
||||
if gateway_dependency_stamp_matches(&dependency_stamp)? {
|
||||
return Ok(dependency_stamp);
|
||||
}
|
||||
let build_dir = gateway_dir().join("_build/default");
|
||||
println!(
|
||||
"Gateway dependency inputs changed; removing stale {}",
|
||||
build_dir.display()
|
||||
);
|
||||
match fs::remove_dir_all(&build_dir) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(error).with_context(|| format!("failed to remove {}", build_dir.display()));
|
||||
}
|
||||
}
|
||||
Ok(dependency_stamp)
|
||||
}
|
||||
|
||||
fn cluster_cookie() -> String {
|
||||
env::var("FLUXER_ERLANG_COOKIE").unwrap_or_else(|_| GATEWAY_CLUSTER_COOKIE.to_owned())
|
||||
fn gateway_dependency_stamp() -> Result<String> {
|
||||
let mut hasher = Sha256::new();
|
||||
let mut total_bytes = 0_u64;
|
||||
for input in GATEWAY_DEPENDENCY_INPUTS {
|
||||
let path = gateway_dir().join(input);
|
||||
let metadata = fs::metadata(&path)
|
||||
.with_context(|| format!("failed to read metadata for {}", path.display()))?;
|
||||
if !metadata.is_file() {
|
||||
bail!("gateway dependency input is not a file: {}", path.display());
|
||||
}
|
||||
total_bytes = total_bytes
|
||||
.checked_add(metadata.len())
|
||||
.context("gateway dependency input byte count overflowed")?;
|
||||
if total_bytes > GATEWAY_DEPENDENCY_INPUT_MAX_BYTES {
|
||||
bail!(
|
||||
"gateway dependency inputs exceed {} bytes",
|
||||
GATEWAY_DEPENDENCY_INPUT_MAX_BYTES
|
||||
);
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(metadata.len() as usize);
|
||||
fs::File::open(&path)
|
||||
.with_context(|| format!("failed to open {}", path.display()))?
|
||||
.take(metadata.len() + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
if bytes.len() as u64 != metadata.len() {
|
||||
bail!(
|
||||
"gateway dependency input changed while reading: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
hasher.update((input.len() as u64).to_le_bytes());
|
||||
hasher.update(input.as_bytes());
|
||||
hasher.update(metadata.len().to_le_bytes());
|
||||
hasher.update(&bytes);
|
||||
}
|
||||
Ok(bytes_to_lower_hex(&hasher.finalize()))
|
||||
}
|
||||
|
||||
fn bytes_to_lower_hex(bytes: &[u8]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut output = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
output.push(HEX[(byte >> 4) as usize] as char);
|
||||
output.push(HEX[(byte & 0x0f) as usize] as char);
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn gateway_dependency_stamp_matches(expected: &str) -> Result<bool> {
|
||||
let path = DEV_GATEWAY_DIR.join(GATEWAY_DEPENDENCY_STAMP_FILE);
|
||||
let file = match fs::File::open(&path) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(error) => {
|
||||
return Err(error).with_context(|| format!("failed to open {}", path.display()));
|
||||
}
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
file.take(GATEWAY_DEPENDENCY_STAMP_MAX_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
if bytes.len() as u64 > GATEWAY_DEPENDENCY_STAMP_MAX_BYTES {
|
||||
bail!(
|
||||
"gateway dependency stamp exceeds {} bytes: {}",
|
||||
GATEWAY_DEPENDENCY_STAMP_MAX_BYTES,
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Ok(bytes == format!("{expected}\n").as_bytes())
|
||||
}
|
||||
|
||||
fn write_gateway_dependency_stamp(dependency_stamp: &str) -> Result<()> {
|
||||
fs::create_dir_all(DEV_GATEWAY_DIR.as_path())
|
||||
.with_context(|| format!("failed to create {}", DEV_GATEWAY_DIR.display()))?;
|
||||
let path = DEV_GATEWAY_DIR.join(GATEWAY_DEPENDENCY_STAMP_FILE);
|
||||
fs::write(&path, format!("{dependency_stamp}\n"))
|
||||
.with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
async fn restart_exited_nodes(supervised: &mut [SupervisedNode], static_peers: &str) -> Result<()> {
|
||||
@@ -285,18 +391,7 @@ async fn restart_exited_nodes(supervised: &mut [SupervisedNode], static_peers: &
|
||||
}
|
||||
|
||||
async fn restart_node(entry: &mut SupervisedNode, static_peers: &str) -> Result<()> {
|
||||
if entry.child.try_wait()?.is_none() {
|
||||
terminate_process(&mut entry.child);
|
||||
let deadline = Instant::now() + NODE_SHUTDOWN_TIMEOUT;
|
||||
while entry.child.try_wait()?.is_none() {
|
||||
if Instant::now() >= deadline {
|
||||
let _ = entry.child.kill();
|
||||
let _ = entry.child.wait();
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
stop_process_group(&mut entry.child, NODE_SHUTDOWN_TIMEOUT).await;
|
||||
wait_for_ports_available_until(
|
||||
std::slice::from_ref(&entry.node),
|
||||
Instant::now() + NODE_RESTART_PORT_WAIT,
|
||||
@@ -306,79 +401,13 @@ async fn restart_node(entry: &mut SupervisedNode, static_peers: &str) -> Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_hot_reload(
|
||||
supervised: &mut [SupervisedNode],
|
||||
before: &ArtifactState,
|
||||
static_peers: &str,
|
||||
) -> Result<()> {
|
||||
let after = snapshot_artifacts();
|
||||
let diff = changed_artifacts(before, &after);
|
||||
if diff.nifs_changed {
|
||||
println!("Gateway native NIF artifacts changed; rolling restart of all gateway nodes...");
|
||||
for entry in supervised.iter_mut() {
|
||||
println!("[gateway:{}] restarting for NIF reload", entry.node.name());
|
||||
restart_node(entry, static_peers).await?;
|
||||
}
|
||||
println!("Gateway rolling restart complete");
|
||||
return Ok(());
|
||||
}
|
||||
if diff.modules.is_empty() {
|
||||
println!("Gateway compile finished; no module changes to reload");
|
||||
return Ok(());
|
||||
}
|
||||
println!(
|
||||
"Hot reloading {} gateway module(s) across {} node(s): {}",
|
||||
diff.modules.len(),
|
||||
supervised.len(),
|
||||
diff.modules.join(", ")
|
||||
);
|
||||
let nodes = supervised
|
||||
.iter()
|
||||
.map(|entry| entry.node.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let modules = diff.modules.clone();
|
||||
let cookie = cluster_cookie();
|
||||
let outcome =
|
||||
tokio::task::spawn_blocking(move || hot_reload_modules(&nodes, &modules, &cookie)).await;
|
||||
match outcome {
|
||||
Ok(Ok(outcome)) if outcome.failed_nodes.is_empty() => {
|
||||
println!(
|
||||
"Gateway hot reload complete ({} module(s) live)",
|
||||
diff.modules.len()
|
||||
);
|
||||
}
|
||||
Ok(Ok(outcome)) => {
|
||||
for entry in supervised.iter_mut() {
|
||||
if !outcome.failed_nodes.contains(&entry.node.erlang_name()) {
|
||||
continue;
|
||||
}
|
||||
if entry.child.try_wait()?.is_some() {
|
||||
continue;
|
||||
}
|
||||
println!(
|
||||
"[gateway:{}] hot reload failed; restarting node to pick up new code",
|
||||
entry.node.name()
|
||||
);
|
||||
restart_node(entry, static_peers).await?;
|
||||
}
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
println!("Gateway hot reload failed: {error}; rolling restart of all gateway nodes");
|
||||
for entry in supervised.iter_mut() {
|
||||
restart_node(entry, static_peers).await?;
|
||||
}
|
||||
}
|
||||
Err(error) => println!("Gateway hot reload task failed: {error}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop_supervised(supervised: &mut [SupervisedNode]) {
|
||||
let mut children = supervised
|
||||
.iter_mut()
|
||||
.map(|entry| &mut entry.child)
|
||||
.collect::<Vec<_>>();
|
||||
stop_child_processes(&mut children);
|
||||
stop_idle_epmd();
|
||||
}
|
||||
|
||||
pub fn build_gateway_cluster_nodes() -> Result<Vec<GatewayNode>> {
|
||||
@@ -492,18 +521,20 @@ fn can_bind_tcp_port(port: u16) -> bool {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn cleanup_orphaned_gateway_nodes(nodes: &[GatewayNode]) -> Result<()> {
|
||||
let leaders = orphaned_gateway_leaders(nodes)?;
|
||||
async fn cleanup_orphaned_gateway_processes(
|
||||
node_names: &std::collections::HashSet<String>,
|
||||
) -> Result<()> {
|
||||
let leaders = orphaned_gateway_leaders(node_names)?;
|
||||
if leaders.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let pids = leaders.iter().map(|leader| leader.pid).collect::<Vec<_>>();
|
||||
|
||||
println!(
|
||||
"Stopping orphaned gateway cluster node process group(s): {}",
|
||||
"Stopping orphaned gateway process group(s): {}",
|
||||
format_pids(&pids)
|
||||
);
|
||||
let term_failed = signal_process_groups(&pids, libc::SIGTERM);
|
||||
let term_failed = signal_process_groups(&leaders, libc::SIGTERM);
|
||||
if !term_failed.is_empty() {
|
||||
println!(
|
||||
"SIGTERM delivery failed for orphaned gateway pid(s): {}",
|
||||
@@ -518,7 +549,12 @@ async fn cleanup_orphaned_gateway_nodes(nodes: &[GatewayNode]) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let kill_failed = signal_process_groups(&remaining, libc::SIGKILL);
|
||||
let remaining_leaders = leaders
|
||||
.iter()
|
||||
.filter(|leader| remaining.contains(&leader.pid))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let kill_failed = signal_process_groups(&remaining_leaders, libc::SIGKILL);
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
let survivors = surviving_gateway_group_pids(&leaders)?;
|
||||
if survivors.is_empty() {
|
||||
@@ -556,24 +592,95 @@ fn format_pids(pids: &[i32]) -> String {
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
async fn cleanup_orphaned_gateway_nodes(_nodes: &[GatewayNode]) -> Result<()> {
|
||||
async fn cleanup_orphaned_gateway_processes(
|
||||
_node_names: &std::collections::HashSet<String>,
|
||||
) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct GatewayLeader {
|
||||
pid: i32,
|
||||
members: Vec<GatewayProcessIdentity>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct GatewayProcessIdentity {
|
||||
pid: i32,
|
||||
starttime: u64,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn orphaned_gateway_leaders(nodes: &[GatewayNode]) -> Result<Vec<GatewayLeader>> {
|
||||
let node_names = nodes
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct GatewayProcess {
|
||||
pid: i32,
|
||||
ppid: i32,
|
||||
pgid: i32,
|
||||
state: char,
|
||||
starttime: u64,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn orphaned_gateway_leaders(
|
||||
node_names: &std::collections::HashSet<String>,
|
||||
) -> Result<Vec<GatewayLeader>> {
|
||||
let current_exe = std::env::current_exe().context("failed to resolve fluxer-dev executable")?;
|
||||
let processes = gateway_process_snapshot()?;
|
||||
let mut owned_pids = BTreeSet::new();
|
||||
for process in &processes {
|
||||
if process.ppid != 1 || proc_stat_state_is_dead(process.state) {
|
||||
continue;
|
||||
}
|
||||
let args = proc_cmdline(process.pid);
|
||||
let orphaned_supervisor = cmdline_is_gateway_supervisor(&args, ¤t_exe);
|
||||
let orphaned_node = (cmdline_has_gateway_node(&args, node_names)
|
||||
|| cmdline_has_managed_gateway_config(&args))
|
||||
&& fs::read_link(format!("/proc/{}/cwd", process.pid))
|
||||
.ok()
|
||||
.as_deref()
|
||||
== Some(gateway_dir().as_path());
|
||||
if orphaned_supervisor || orphaned_node {
|
||||
owned_pids.insert(process.pid);
|
||||
}
|
||||
}
|
||||
loop {
|
||||
let previous_count = owned_pids.len();
|
||||
for process in &processes {
|
||||
if owned_pids.contains(&process.ppid) {
|
||||
owned_pids.insert(process.pid);
|
||||
}
|
||||
}
|
||||
if owned_pids.len() == previous_count {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let group_pids = processes
|
||||
.iter()
|
||||
.map(GatewayNode::erlang_name)
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
let mut leaders = Vec::new();
|
||||
.filter(|process| owned_pids.contains(&process.pid) && process.pgid > 1)
|
||||
.map(|process| process.pgid)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let leaders = group_pids
|
||||
.into_iter()
|
||||
.map(|pid| GatewayLeader {
|
||||
pid,
|
||||
members: processes
|
||||
.iter()
|
||||
.filter(|process| owned_pids.contains(&process.pid) && process.pgid == pid)
|
||||
.map(|process| GatewayProcessIdentity {
|
||||
pid: process.pid,
|
||||
starttime: process.starttime,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(leaders)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn gateway_process_snapshot() -> Result<Vec<GatewayProcess>> {
|
||||
let mut processes = Vec::new();
|
||||
for entry in fs::read_dir("/proc").context("failed to read /proc")? {
|
||||
let Ok(entry) = entry else {
|
||||
continue;
|
||||
@@ -585,18 +692,39 @@ fn orphaned_gateway_leaders(nodes: &[GatewayNode]) -> Result<Vec<GatewayLeader>>
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !cmdline_has_gateway_node(&proc_cmdline(pid), &node_names) {
|
||||
let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) else {
|
||||
continue;
|
||||
}
|
||||
if proc_parent_pid(pid) != Some(1) {
|
||||
};
|
||||
let Some((state, ppid, pgid, starttime)) = parse_proc_stat_process(&stat) else {
|
||||
continue;
|
||||
}
|
||||
if let Some(starttime) = proc_stat_starttime(pid) {
|
||||
leaders.push(GatewayLeader { pid, starttime });
|
||||
}
|
||||
};
|
||||
processes.push(GatewayProcess {
|
||||
pid,
|
||||
ppid,
|
||||
pgid,
|
||||
state,
|
||||
starttime,
|
||||
});
|
||||
}
|
||||
leaders.sort_unstable_by_key(|leader| leader.pid);
|
||||
Ok(leaders)
|
||||
Ok(processes)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cmdline_is_gateway_supervisor(args: &[String], current_exe: &Path) -> bool {
|
||||
args.first().map(Path::new) == Some(current_exe)
|
||||
&& args.get(1).map(String::as_str) == Some("gateway")
|
||||
&& match args.get(2).map(String::as_str) {
|
||||
None | Some("cluster" | "single") => args.len() <= 3,
|
||||
Some(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cmdline_has_managed_gateway_config(args: &[String]) -> bool {
|
||||
args.windows(2).any(|pair| {
|
||||
matches!(pair[0].as_str(), "-config" | "-args_file")
|
||||
&& Path::new(&pair[1]).starts_with(DEV_GATEWAY_DIR.as_path())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
@@ -618,30 +746,6 @@ fn proc_cmdline(pid: i32) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn proc_parent_pid(pid: i32) -> Option<i32> {
|
||||
fs::read_to_string(format!("/proc/{pid}/status"))
|
||||
.ok()?
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("PPid:")?.trim().parse().ok())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn process_exists(pid: i32) -> bool {
|
||||
assert!(pid > 0);
|
||||
match proc_stat_state_and_pgid(pid) {
|
||||
Some((state, _pgid)) => !proc_stat_state_is_dead(state),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn proc_stat_state_and_pgid(pid: i32) -> Option<(char, i32)> {
|
||||
assert!(pid > 0);
|
||||
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
parse_proc_stat_state_and_pgid(&stat)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn parse_proc_stat_state_and_pgid(stat: &str) -> Option<(char, i32)> {
|
||||
let (_, after_comm) = stat.rsplit_once(')')?;
|
||||
@@ -653,10 +757,13 @@ fn parse_proc_stat_state_and_pgid(stat: &str) -> Option<(char, i32)> {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn proc_stat_starttime(pid: i32) -> Option<u64> {
|
||||
assert!(pid > 0);
|
||||
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
parse_proc_stat_starttime(&stat)
|
||||
fn parse_proc_stat_process(stat: &str) -> Option<(char, i32, i32, u64)> {
|
||||
let (_, after_comm) = stat.rsplit_once(')')?;
|
||||
let mut fields = after_comm.split_ascii_whitespace();
|
||||
let state = fields.next()?.chars().next()?;
|
||||
let ppid = fields.next()?.parse().ok()?;
|
||||
let pgid = fields.next()?.parse().ok()?;
|
||||
Some((state, ppid, pgid, parse_proc_stat_starttime(stat)?))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
@@ -672,62 +779,43 @@ fn proc_stat_state_is_dead(state: char) -> bool {
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn surviving_gateway_group_pids(leaders: &[GatewayLeader]) -> Result<Vec<i32>> {
|
||||
let leader_pids = leaders
|
||||
Ok(leaders
|
||||
.iter()
|
||||
.filter(|leader| gateway_group_has_owned_member(leader))
|
||||
.map(|leader| leader.pid)
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
let mut survivors = Vec::new();
|
||||
for entry in fs::read_dir("/proc").context("failed to read /proc")? {
|
||||
let Ok(entry) = entry else {
|
||||
continue;
|
||||
};
|
||||
let Some(pid) = entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.and_then(|name| name.parse::<i32>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) else {
|
||||
continue;
|
||||
};
|
||||
let Some((state, pgid)) = parse_proc_stat_state_and_pgid(&stat) else {
|
||||
continue;
|
||||
};
|
||||
if proc_stat_state_is_dead(state) {
|
||||
continue;
|
||||
}
|
||||
if leader_pids.contains(&pgid) {
|
||||
survivors.push(pid);
|
||||
continue;
|
||||
}
|
||||
let leader = leaders.iter().find(|leader| leader.pid == pid);
|
||||
if let Some(leader) = leader
|
||||
&& parse_proc_stat_starttime(&stat) == Some(leader.starttime)
|
||||
{
|
||||
survivors.push(pid);
|
||||
}
|
||||
}
|
||||
survivors.sort_unstable();
|
||||
Ok(survivors)
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn signal_process_groups(pids: &[i32], signal: i32) -> Vec<i32> {
|
||||
fn gateway_group_has_owned_member(leader: &GatewayLeader) -> bool {
|
||||
leader.members.iter().any(|member| {
|
||||
let Ok(stat) = fs::read_to_string(format!("/proc/{}/stat", member.pid)) else {
|
||||
return false;
|
||||
};
|
||||
let Some((state, pgid)) = parse_proc_stat_state_and_pgid(&stat) else {
|
||||
return false;
|
||||
};
|
||||
!proc_stat_state_is_dead(state)
|
||||
&& pgid == leader.pid
|
||||
&& parse_proc_stat_starttime(&stat) == Some(member.starttime)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn signal_process_groups(leaders: &[GatewayLeader], signal: i32) -> Vec<i32> {
|
||||
assert!(signal == libc::SIGTERM || signal == libc::SIGKILL);
|
||||
let mut failed = Vec::with_capacity(pids.len());
|
||||
for pid in pids {
|
||||
assert!(*pid > 0);
|
||||
let group_result = unsafe { libc::kill(-pid, signal) };
|
||||
let mut failed = Vec::with_capacity(leaders.len());
|
||||
for leader in leaders {
|
||||
assert!(leader.pid > 1);
|
||||
if !gateway_group_has_owned_member(leader) {
|
||||
continue;
|
||||
}
|
||||
let group_result = unsafe { libc::kill(-leader.pid, signal) };
|
||||
if group_result == 0 {
|
||||
continue;
|
||||
}
|
||||
let direct_result = unsafe { libc::kill(*pid, signal) };
|
||||
if direct_result == 0 {
|
||||
continue;
|
||||
}
|
||||
if process_exists(*pid) {
|
||||
failed.push(*pid);
|
||||
if gateway_group_has_owned_member(leader) {
|
||||
failed.push(leader.pid);
|
||||
}
|
||||
}
|
||||
failed
|
||||
@@ -812,14 +900,7 @@ fn start_node(node: &GatewayNode, static_peers: &str) -> Result<Child> {
|
||||
.envs(env)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
use std::os::unix::process::CommandExt;
|
||||
child_command.pre_exec(|| {
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
configure_process_group(&mut child_command);
|
||||
let mut child = child_command.spawn()?;
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let label = node.name();
|
||||
@@ -832,30 +913,30 @@ fn start_node(node: &GatewayNode, static_peers: &str) -> Result<Child> {
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
fn prefix_output(label: &str, reader: impl std::io::Read) {
|
||||
use std::io::{BufRead, BufReader};
|
||||
for line in BufReader::new(reader).lines().map_while(|line| line.ok()) {
|
||||
println!("[{label}] {line}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_processes(processes: &mut [Child]) {
|
||||
let mut children = processes.iter_mut().collect::<Vec<_>>();
|
||||
stop_child_processes(&mut children);
|
||||
stop_child_processes_with_grace(&mut children, ROOT_STOP_GRACE_PERIOD);
|
||||
stop_idle_epmd();
|
||||
}
|
||||
|
||||
pub fn stop_child_processes(processes: &mut [&mut Child]) {
|
||||
stop_child_processes_with_grace(processes, NESTED_STOP_GRACE_PERIOD);
|
||||
}
|
||||
|
||||
fn stop_child_processes_with_grace(processes: &mut [&mut Child], grace_period: Duration) {
|
||||
for process in processes.iter_mut() {
|
||||
if process.try_wait().ok().flatten().is_some() {
|
||||
continue;
|
||||
if let Err(error) = terminate_process_group(process) {
|
||||
eprintln!(
|
||||
"Failed to terminate process group {}: {error}",
|
||||
process.id()
|
||||
);
|
||||
}
|
||||
terminate_process(process);
|
||||
}
|
||||
let deadline = Instant::now() + STOP_GRACE_PERIOD;
|
||||
let deadline = Instant::now() + grace_period;
|
||||
loop {
|
||||
let all_exited = processes
|
||||
.iter_mut()
|
||||
.all(|process| process.try_wait().ok().flatten().is_some());
|
||||
let all_exited = processes.iter_mut().all(|process| {
|
||||
process.try_wait().ok().flatten().is_some() && !process_group_running(process)
|
||||
});
|
||||
if all_exited {
|
||||
return;
|
||||
}
|
||||
@@ -865,38 +946,30 @@ pub fn stop_child_processes(processes: &mut [&mut Child]) {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
for process in processes {
|
||||
if process.try_wait().ok().flatten().is_some() {
|
||||
continue;
|
||||
if process.try_wait().ok().flatten().is_none() || process_group_running(process) {
|
||||
force_kill_process_group(process);
|
||||
}
|
||||
force_kill_process(process);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn terminate_process(process: &mut Child) {
|
||||
unsafe {
|
||||
libc::kill(-(process.id() as i32), libc::SIGTERM);
|
||||
fn stop_idle_epmd() {
|
||||
let output = match Command::new("epmd").arg("-kill").output() {
|
||||
Ok(output) => output,
|
||||
Err(error) => {
|
||||
eprintln!("Failed to stop Erlang port mapper: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if output.status.success() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn terminate_process(process: &mut Child) {
|
||||
let _ = process.kill();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn force_kill_process(process: &mut Child) {
|
||||
unsafe {
|
||||
libc::kill(-(process.id() as i32), libc::SIGKILL);
|
||||
let mut message = output.stdout;
|
||||
message.extend(output.stderr);
|
||||
let message = String::from_utf8_lossy(&message);
|
||||
if message.contains("Cannot connect to local epmd") {
|
||||
return;
|
||||
}
|
||||
let _ = process.kill();
|
||||
let _ = process.wait();
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn force_kill_process(process: &mut Child) {
|
||||
let _ = process.kill();
|
||||
let _ = process.wait();
|
||||
eprintln!("Failed to stop Erlang port mapper: {}", message.trim());
|
||||
}
|
||||
|
||||
pub fn build_gateway_command(config_dir: &Path) -> Result<Vec<String>> {
|
||||
|
||||
@@ -1,420 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::gateway::{GatewayNode, gateway_dir};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
const WATCH_POLL_INTERVAL: Duration = Duration::from_millis(1000);
|
||||
const WATCH_SETTLE_INTERVAL: Duration = Duration::from_millis(300);
|
||||
const RELOAD_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const WATCHED_SOURCE_EXTENSIONS: &[&str] = &["erl", "hrl", "src", "rs", "toml", "config"];
|
||||
|
||||
pub type FileState = BTreeMap<PathBuf, (SystemTime, u64)>;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ArtifactState {
|
||||
pub beams: FileState,
|
||||
pub nifs: FileState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ArtifactDiff {
|
||||
pub modules: Vec<String>,
|
||||
pub nifs_changed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ReloadOutcome {
|
||||
pub failed_nodes: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn hot_reload_enabled() -> bool {
|
||||
env::var("FLUXER_DEV_GATEWAY_HOT_RELOAD")
|
||||
.map(|value| !matches!(value.to_ascii_lowercase().as_str(), "0" | "false" | "no"))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn spawn_source_watcher() -> Receiver<()> {
|
||||
let (sender, receiver) = channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut previous = scan_sources();
|
||||
loop {
|
||||
std::thread::sleep(WATCH_POLL_INTERVAL);
|
||||
let mut current = scan_sources();
|
||||
if current == previous {
|
||||
continue;
|
||||
}
|
||||
loop {
|
||||
std::thread::sleep(WATCH_SETTLE_INTERVAL);
|
||||
let settled = scan_sources();
|
||||
if settled == current {
|
||||
break;
|
||||
}
|
||||
current = settled;
|
||||
}
|
||||
previous = current;
|
||||
if sender.send(()).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
receiver
|
||||
}
|
||||
|
||||
fn scan_sources() -> FileState {
|
||||
let dir = gateway_dir();
|
||||
let mut state = FileState::new();
|
||||
for root in [dir.join("src"), dir.join("include"), dir.join("native")] {
|
||||
scan_tree(&mut state, &root);
|
||||
}
|
||||
for path in [
|
||||
dir.join("rebar.config"),
|
||||
dir.join("rebar.config.script"),
|
||||
dir.join("rebar.lock"),
|
||||
] {
|
||||
record_file(&mut state, &path);
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
fn scan_tree(state: &mut FileState, root: &Path) {
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Ok(metadata) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
if metadata.is_dir() {
|
||||
if !is_skipped_dir(&path) {
|
||||
scan_tree(state, &path);
|
||||
}
|
||||
} else if is_watched_source(&path)
|
||||
&& let Ok(modified) = metadata.modified()
|
||||
{
|
||||
state.insert(path, (modified, metadata.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_file(state: &mut FileState, path: &Path) {
|
||||
if let Ok(metadata) = fs::metadata(path)
|
||||
&& let Ok(modified) = metadata.modified()
|
||||
{
|
||||
state.insert(path.to_path_buf(), (modified, metadata.len()));
|
||||
}
|
||||
}
|
||||
|
||||
fn is_skipped_dir(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name == "target" || name.starts_with('.'))
|
||||
}
|
||||
|
||||
fn is_watched_source(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| WATCHED_SOURCE_EXTENSIONS.contains(&extension))
|
||||
}
|
||||
|
||||
pub fn snapshot_artifacts() -> ArtifactState {
|
||||
let mut state = ArtifactState::default();
|
||||
let lib_root = gateway_dir().join("_build/default/lib");
|
||||
if let Ok(entries) = fs::read_dir(&lib_root) {
|
||||
for entry in entries.flatten() {
|
||||
scan_artifact_dir(&mut state.beams, &entry.path().join("ebin"), "beam");
|
||||
}
|
||||
}
|
||||
scan_artifact_dir(&mut state.nifs, &gateway_dir().join("priv"), "so");
|
||||
state
|
||||
}
|
||||
|
||||
fn scan_artifact_dir(state: &mut FileState, dir: &Path, extension: &str) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path
|
||||
.extension()
|
||||
.is_some_and(|candidate| candidate == extension)
|
||||
&& let Ok(metadata) = entry.metadata()
|
||||
&& let Ok(modified) = metadata.modified()
|
||||
{
|
||||
state.insert(path, (modified, metadata.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn changed_artifacts(before: &ArtifactState, after: &ArtifactState) -> ArtifactDiff {
|
||||
let modules = after
|
||||
.beams
|
||||
.iter()
|
||||
.filter(|(path, state)| before.beams.get(*path) != Some(*state))
|
||||
.filter_map(|(path, _)| Some(path.file_stem()?.to_str()?.to_owned()))
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let nifs_changed = after
|
||||
.nifs
|
||||
.iter()
|
||||
.any(|(path, state)| before.nifs.get(path) != Some(state));
|
||||
ArtifactDiff {
|
||||
modules,
|
||||
nifs_changed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_reload_eval(nodes: &[String], modules: &[String]) -> String {
|
||||
let node_list = nodes
|
||||
.iter()
|
||||
.map(|node| format!("'{node}'"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let module_list = modules
|
||||
.iter()
|
||||
.map(|module| format!("'{module}'"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!(
|
||||
"Nodes = [{node_list}], \
|
||||
Mods = [{module_list}], \
|
||||
Failed = [Node || Node <- Nodes, \
|
||||
case net_adm:ping(Node) of \
|
||||
pong -> \
|
||||
Errors = [Mod || Mod <- Mods, \
|
||||
begin \
|
||||
rpc:call(Node, code, purge, [Mod], 10000), \
|
||||
case rpc:call(Node, code, load_file, [Mod], 10000) of \
|
||||
{{module, Mod}} -> false; \
|
||||
Other -> \
|
||||
io:format(\"gateway_reload_error ~s ~s ~0p~n\", [Node, Mod, Other]), \
|
||||
true \
|
||||
end \
|
||||
end], \
|
||||
Errors =/= []; \
|
||||
pang -> \
|
||||
io:format(\"gateway_reload_node_down ~s~n\", [Node]), \
|
||||
true \
|
||||
end], \
|
||||
[io:format(\"gateway_reload_failed_node ~s~n\", [Failed1]) || Failed1 <- Failed], \
|
||||
io:format(\"gateway_reload_done~n\"), \
|
||||
halt(0)."
|
||||
)
|
||||
}
|
||||
|
||||
pub fn hot_reload_modules(
|
||||
nodes: &[GatewayNode],
|
||||
modules: &[String],
|
||||
cookie: &str,
|
||||
) -> Result<ReloadOutcome> {
|
||||
assert!(!modules.is_empty());
|
||||
let node_names = nodes
|
||||
.iter()
|
||||
.map(GatewayNode::erlang_name)
|
||||
.collect::<Vec<_>>();
|
||||
let eval = build_reload_eval(&node_names, modules);
|
||||
let reloader_name = format!("fluxer_dev_reload_{}@127.0.0.1", std::process::id());
|
||||
let mut child = Command::new("erl")
|
||||
.args([
|
||||
"-hidden",
|
||||
"-noshell",
|
||||
"-name",
|
||||
&reloader_name,
|
||||
"-setcookie",
|
||||
cookie,
|
||||
"-eval",
|
||||
&eval,
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("failed to spawn gateway reload shell")?;
|
||||
let stdout = child.stdout.take().map(spawn_reader);
|
||||
let stderr = child.stderr.take().map(spawn_reader);
|
||||
let status = wait_with_deadline(&mut child, RELOAD_TIMEOUT)?;
|
||||
let mut output = String::new();
|
||||
for handle in [stdout, stderr].into_iter().flatten() {
|
||||
if let Ok(text) = handle.join() {
|
||||
output.push_str(&text);
|
||||
}
|
||||
}
|
||||
for line in output.lines().filter(|line| !line.trim().is_empty()) {
|
||||
println!("[gateway:reload] {line}");
|
||||
}
|
||||
if !status.success() {
|
||||
bail!(
|
||||
"gateway reload shell exited with status {}",
|
||||
status.code().unwrap_or(1)
|
||||
);
|
||||
}
|
||||
if !output.lines().any(|line| line == "gateway_reload_done") {
|
||||
bail!("gateway reload shell did not report completion");
|
||||
}
|
||||
Ok(parse_reload_outcome(&output))
|
||||
}
|
||||
|
||||
pub fn parse_reload_outcome(output: &str) -> ReloadOutcome {
|
||||
let failed_nodes = output
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("gateway_reload_failed_node "))
|
||||
.map(|node| node.trim().to_owned())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
ReloadOutcome { failed_nodes }
|
||||
}
|
||||
|
||||
fn spawn_reader(stream: impl Read + Send + 'static) -> std::thread::JoinHandle<String> {
|
||||
std::thread::spawn(move || {
|
||||
let mut reader = stream;
|
||||
let mut text = String::new();
|
||||
let mut bytes = Vec::new();
|
||||
if reader.read_to_end(&mut bytes).is_ok() {
|
||||
text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
}
|
||||
text
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_with_deadline(child: &mut Child, timeout: Duration) -> Result<std::process::ExitStatus> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
return Ok(status);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
bail!(
|
||||
"gateway reload shell timed out after {}s",
|
||||
timeout.as_secs()
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn file_state(entries: &[(&str, u64, u64)]) -> FileState {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(path, seconds, size)| {
|
||||
(
|
||||
PathBuf::from(path),
|
||||
(
|
||||
SystemTime::UNIX_EPOCH + Duration::from_secs(*seconds),
|
||||
*size,
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_artifacts_detects_new_and_modified_beams() {
|
||||
let before = ArtifactState {
|
||||
beams: file_state(&[("ebin/a.beam", 1, 10), ("ebin/b.beam", 1, 20)]),
|
||||
nifs: FileState::new(),
|
||||
};
|
||||
let after = ArtifactState {
|
||||
beams: file_state(&[
|
||||
("ebin/a.beam", 2, 10),
|
||||
("ebin/b.beam", 1, 20),
|
||||
("ebin/c.beam", 1, 30),
|
||||
]),
|
||||
nifs: FileState::new(),
|
||||
};
|
||||
let diff = changed_artifacts(&before, &after);
|
||||
assert_eq!(diff.modules, vec!["a".to_owned(), "c".to_owned()]);
|
||||
assert!(!diff.nifs_changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_artifacts_ignores_unchanged_state() {
|
||||
let state = ArtifactState {
|
||||
beams: file_state(&[("ebin/a.beam", 1, 10)]),
|
||||
nifs: file_state(&[("priv/a_nif.so", 1, 10)]),
|
||||
};
|
||||
let diff = changed_artifacts(&state, &state.clone());
|
||||
assert!(diff.modules.is_empty());
|
||||
assert!(!diff.nifs_changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_artifacts_flags_nif_changes() {
|
||||
let before = ArtifactState {
|
||||
beams: FileState::new(),
|
||||
nifs: file_state(&[("priv/a_nif.so", 1, 10)]),
|
||||
};
|
||||
let after = ArtifactState {
|
||||
beams: FileState::new(),
|
||||
nifs: file_state(&[("priv/a_nif.so", 2, 11)]),
|
||||
};
|
||||
assert!(changed_artifacts(&before, &after).nifs_changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_eval_quotes_nodes_and_modules() {
|
||||
let eval = build_reload_eval(
|
||||
&["fluxer_gateway_websocket_1@127.0.0.1".to_owned()],
|
||||
&["gateway_compress".to_owned(), "push".to_owned()],
|
||||
);
|
||||
assert!(eval.contains("Nodes = ['fluxer_gateway_websocket_1@127.0.0.1']"));
|
||||
assert!(eval.contains("Mods = ['gateway_compress','push']"));
|
||||
assert!(eval.contains("halt(0)."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_outcome_parses_failed_nodes() {
|
||||
let output = "gateway_reload_error n1 mod {error,nofile}\n\
|
||||
gateway_reload_failed_node fluxer_gateway_guilds_2@127.0.0.1\n\
|
||||
gateway_reload_failed_node fluxer_gateway_guilds_2@127.0.0.1\n\
|
||||
gateway_reload_done\n";
|
||||
let outcome = parse_reload_outcome(output);
|
||||
assert_eq!(
|
||||
outcome.failed_nodes,
|
||||
vec!["fluxer_gateway_guilds_2@127.0.0.1".to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_outcome_empty_when_no_failures() {
|
||||
assert!(
|
||||
parse_reload_outcome("gateway_reload_done\n")
|
||||
.failed_nodes
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watched_source_filter_accepts_known_extensions() {
|
||||
assert!(is_watched_source(Path::new("src/push.erl")));
|
||||
assert!(is_watched_source(Path::new("include/gateway.hrl")));
|
||||
assert!(is_watched_source(Path::new("src/fluxer_gateway.app.src")));
|
||||
assert!(is_watched_source(Path::new("native/a_nif/src/lib.rs")));
|
||||
assert!(is_watched_source(Path::new("native/a_nif/Cargo.toml")));
|
||||
assert!(!is_watched_source(Path::new("ebin/push.beam")));
|
||||
assert!(!is_watched_source(Path::new("src/.push.erl.swp")));
|
||||
assert!(!is_watched_source(Path::new("native/a_nif/Cargo.lock")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_dirs_cover_build_output_and_hidden_dirs() {
|
||||
assert!(is_skipped_dir(Path::new("native/a_nif/target")));
|
||||
assert!(is_skipped_dir(Path::new("src/.git")));
|
||||
assert!(!is_skipped_dir(Path::new("src/gateway")));
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ pub mod dev;
|
||||
pub mod disclaim;
|
||||
pub mod env;
|
||||
pub mod gateway;
|
||||
pub mod gateway_reload;
|
||||
pub mod manifest;
|
||||
pub mod media_external;
|
||||
pub mod media_proxy;
|
||||
|
||||
+107
-7
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use fluxer_dev::cassandra::{
|
||||
apply_schema, compute_diff, render_target_schema, verify_schema, write_diff_file,
|
||||
@@ -11,9 +11,19 @@ use fluxer_dev::desktop::{
|
||||
};
|
||||
use fluxer_dev::env::merge_default_env_with_current;
|
||||
use fluxer_dev::manifest::{DEV_PROXY_PORT, LOCAL_APP_URL};
|
||||
use fluxer_dev::paths::{DEV_ENV_FILE, DEV_LOCAL_ENV_FILE, ROOT_LOCAL_ENV_FILE};
|
||||
use fluxer_dev::paths::{DEV_ENV_FILE, DEV_LOCAL_ENV_FILE, ROOT, ROOT_LOCAL_ENV_FILE};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command as ProcessCommand;
|
||||
|
||||
const DEV_INFRA_SERVICES: &[&str] = &[
|
||||
"postgres",
|
||||
"valkey",
|
||||
"nats",
|
||||
"livekit",
|
||||
"meilisearch",
|
||||
"mailpit",
|
||||
];
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "fluxer-dev")]
|
||||
@@ -34,6 +44,7 @@ enum Command {
|
||||
Proxy(ProxyArgs),
|
||||
Dev(DevArgs),
|
||||
RustServices(RustServicesArgs),
|
||||
Infra(InfraArgs),
|
||||
Cassandra(CassandraArgs),
|
||||
Desktop(DesktopArgs),
|
||||
MediaProxy(MediaProxyArgs),
|
||||
@@ -44,8 +55,6 @@ enum Command {
|
||||
struct BootstrapArgs {
|
||||
#[arg(long)]
|
||||
skip_install: bool,
|
||||
#[arg(long)]
|
||||
skip_desktop_install: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
@@ -76,6 +85,19 @@ struct RustServicesArgs {
|
||||
services: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
struct InfraArgs {
|
||||
#[command(subcommand)]
|
||||
command: InfraCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum InfraCommand {
|
||||
Start,
|
||||
Stop,
|
||||
Status,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
struct CassandraArgs {
|
||||
#[command(subcommand)]
|
||||
@@ -193,10 +215,12 @@ async fn main() -> Result<()> {
|
||||
|
||||
match cli.command {
|
||||
Command::Bootstrap(args) => {
|
||||
fluxer_dev::bootstrap::bootstrap(args.skip_install, args.skip_desktop_install).await?;
|
||||
fluxer_dev::bootstrap::bootstrap(args.skip_install).await?;
|
||||
}
|
||||
Command::PostStart => fluxer_dev::bootstrap::post_start().await?,
|
||||
Command::Gateway(args) if args.mode == "single" => fluxer_dev::gateway::run_gateway()?,
|
||||
Command::Gateway(args) if args.mode == "single" => {
|
||||
std::process::exit(fluxer_dev::gateway::run_gateway().await?)
|
||||
}
|
||||
Command::Gateway(_) => {
|
||||
std::process::exit(fluxer_dev::gateway::run_gateway_cluster().await?)
|
||||
}
|
||||
@@ -216,6 +240,7 @@ async fn main() -> Result<()> {
|
||||
Command::RustServices(args) => {
|
||||
std::process::exit(fluxer_dev::rust_services::run_rust_services(&args.services).await?)
|
||||
}
|
||||
Command::Infra(args) => run_infra(args.command)?,
|
||||
Command::Cassandra(args) => match args.command {
|
||||
CassandraCommand::Diff { output } => {
|
||||
let diff = compute_diff(None).await?;
|
||||
@@ -315,3 +340,78 @@ fn apply_default_env() -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_infra(command: InfraCommand) -> Result<()> {
|
||||
let project = compose_project_name()?;
|
||||
let compose_file = ROOT.join(".devcontainer/docker-compose.yml");
|
||||
let mut args = vec![
|
||||
"compose".to_owned(),
|
||||
"--project-name".to_owned(),
|
||||
project,
|
||||
"-f".to_owned(),
|
||||
compose_file.display().to_string(),
|
||||
];
|
||||
match command {
|
||||
InfraCommand::Start => args.push("start".to_owned()),
|
||||
InfraCommand::Stop => args.push("stop".to_owned()),
|
||||
InfraCommand::Status => {
|
||||
args.push("ps".to_owned());
|
||||
args.push("--all".to_owned());
|
||||
}
|
||||
}
|
||||
args.extend(
|
||||
DEV_INFRA_SERVICES
|
||||
.iter()
|
||||
.map(|service| (*service).to_owned()),
|
||||
);
|
||||
let status = ProcessCommand::new("docker")
|
||||
.args(&args)
|
||||
.status()
|
||||
.context("failed to run Docker Compose for the dev infrastructure")?;
|
||||
if !status.success() {
|
||||
bail!("Docker Compose dev infrastructure command failed with {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compose_project_name() -> Result<String> {
|
||||
if Path::new("/.dockerenv").exists() {
|
||||
let container = std::fs::read_to_string("/etc/hostname")
|
||||
.context("failed to read the current devcontainer hostname")?;
|
||||
let container = container.trim();
|
||||
if container.is_empty()
|
||||
|| !container
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
|
||||
{
|
||||
bail!("Current devcontainer hostname is invalid");
|
||||
}
|
||||
let output = ProcessCommand::new("docker")
|
||||
.args([
|
||||
"inspect",
|
||||
"--format",
|
||||
"{{ index .Config.Labels \"com.docker.compose.project\" }}",
|
||||
container,
|
||||
])
|
||||
.output()
|
||||
.context("failed to inspect the current devcontainer Compose project")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"Could not discover the current devcontainer Compose project: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
let project = String::from_utf8(output.stdout)
|
||||
.context("devcontainer Compose project label is not valid UTF-8")?
|
||||
.trim()
|
||||
.to_owned();
|
||||
if project.is_empty() || project == "<no value>" {
|
||||
bail!("Current container has no com.docker.compose.project label");
|
||||
}
|
||||
return Ok(project);
|
||||
}
|
||||
std::env::var("COMPOSE_PROJECT_NAME")
|
||||
.ok()
|
||||
.filter(|project| !project.trim().is_empty())
|
||||
.context("COMPOSE_PROJECT_NAME must be set when managing dev infrastructure from the host")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ pub const LOOPBACK_HOST: &str = "127.0.0.1";
|
||||
pub const ANY_HOST: &str = "0.0.0.0";
|
||||
|
||||
pub const DEV_PROXY_PORT: u16 = 8088;
|
||||
pub const DEV_PROXY_GATEWAY_PORTS_ENV: &str = "FLUXER_DEV_PROXY_GATEWAY_PORTS";
|
||||
pub const APP_PORT: u16 = 3000;
|
||||
pub const APP_PROXY_PORT: u16 = 8773;
|
||||
pub const ADMIN_PORT: u16 = 3020;
|
||||
|
||||
@@ -17,6 +17,8 @@ const DEV_S3_ACCESS_KEY_ID: &str = "fluxer";
|
||||
const DEV_S3_SECRET_ACCESS_KEY: &str = "fluxer-secret";
|
||||
const DEV_S3_HOST: &str = "127.0.0.1";
|
||||
const DEV_S3_PORT: u16 = 8333;
|
||||
const DEV_SEAWEEDFS_STOP_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const DEV_SEAWEEDFS_KILL_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
pub async fn run_dev_media_doctor(
|
||||
repair: bool,
|
||||
@@ -35,10 +37,20 @@ pub async fn run_dev_media_doctor(
|
||||
}
|
||||
|
||||
pub async fn ensure_dev_object_store(repair: bool, repair_timeout_secs: u64) -> Result<()> {
|
||||
let managed_process_running =
|
||||
read_dev_seaweedfs_pid()?.is_some_and(managed_seaweedfs_process_running);
|
||||
if repair && !tcp_reachable(DEV_S3_HOST, DEV_S3_PORT) && !managed_process_running {
|
||||
println!("SeaweedFS S3 is not running; starting the managed development instance.");
|
||||
start_dev_seaweedfs()?;
|
||||
wait_s3_api(repair_timeout_secs).await?;
|
||||
ensure_s3_buckets()?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match wait_s3_api(5).await {
|
||||
Ok(()) => {}
|
||||
Err(error) if repair => {
|
||||
println!("SeaweedFS S3 check failed: {error}");
|
||||
Err(_) if repair => {
|
||||
println!("SeaweedFS S3 is unresponsive; restarting the managed development instance.");
|
||||
start_dev_seaweedfs()?;
|
||||
wait_s3_api(repair_timeout_secs).await?;
|
||||
}
|
||||
@@ -128,7 +140,7 @@ fn start_dev_seaweedfs() -> Result<()> {
|
||||
if let Some(pid) = read_dev_seaweedfs_pid()? {
|
||||
if managed_seaweedfs_process_running(pid) {
|
||||
eprintln!("Stopping unresponsive managed SeaweedFS process {pid}.");
|
||||
stop_managed_seaweedfs_process(pid);
|
||||
stop_managed_seaweedfs_process(pid)?;
|
||||
}
|
||||
let _ = fs::remove_file(DEV_SEAWEEDFS_PID_FILE.as_path());
|
||||
}
|
||||
@@ -141,15 +153,24 @@ fn start_dev_seaweedfs() -> Result<()> {
|
||||
let log_path = DEV_LOG_DIR.join("seaweedfs.log");
|
||||
let log = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&log_path)
|
||||
.with_context(|| format!("failed to open SeaweedFS log {}", log_path.display()))?;
|
||||
let stderr = log
|
||||
.try_clone()
|
||||
.context("failed to clone SeaweedFS log handle")?;
|
||||
let data_dir_arg = format!("-dir={}", DEV_SEAWEEDFS_DIR.display());
|
||||
let child = Command::new(weed)
|
||||
.args(["-logtostderr=true", "mini", &data_dir_arg])
|
||||
let mut child = Command::new(weed)
|
||||
.args([
|
||||
"-logtostderr=true",
|
||||
"mini",
|
||||
&data_dir_arg,
|
||||
"-admin.ui=false",
|
||||
"-filer.disableDirListing",
|
||||
"-s3.port.iceberg=0",
|
||||
"-webdav=false",
|
||||
])
|
||||
.env("AWS_ACCESS_KEY_ID", DEV_S3_ACCESS_KEY_ID)
|
||||
.env("AWS_SECRET_ACCESS_KEY", DEV_S3_SECRET_ACCESS_KEY)
|
||||
.env("S3_BUCKET", DEV_S3_BUCKETS)
|
||||
@@ -159,12 +180,16 @@ fn start_dev_seaweedfs() -> Result<()> {
|
||||
.spawn()
|
||||
.context("failed to start SeaweedFS")?;
|
||||
let pid = child.id();
|
||||
fs::write(DEV_SEAWEEDFS_PID_FILE.as_path(), pid.to_string()).with_context(|| {
|
||||
format!(
|
||||
"failed to write SeaweedFS pid file {}",
|
||||
DEV_SEAWEEDFS_PID_FILE.display()
|
||||
)
|
||||
})?;
|
||||
if let Err(error) = fs::write(DEV_SEAWEEDFS_PID_FILE.as_path(), pid.to_string()) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(error).with_context(|| {
|
||||
format!(
|
||||
"failed to write SeaweedFS pid file {}",
|
||||
DEV_SEAWEEDFS_PID_FILE.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
println!(
|
||||
"Started SeaweedFS dev object store with pid {pid}; logs: {}",
|
||||
log_path.display()
|
||||
@@ -195,20 +220,44 @@ fn managed_seaweedfs_process_running(pid: u32) -> bool {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn stop_managed_seaweedfs_process(pid: u32) {
|
||||
unsafe {
|
||||
libc::kill(pid as i32, libc::SIGTERM);
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
if managed_seaweedfs_process_running(pid) {
|
||||
unsafe {
|
||||
libc::kill(pid as i32, libc::SIGKILL);
|
||||
fn stop_managed_seaweedfs_process(pid: u32) -> Result<()> {
|
||||
let result = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
|
||||
if result == -1 {
|
||||
let error = std::io::Error::last_os_error();
|
||||
if error.raw_os_error() != Some(libc::ESRCH) {
|
||||
return Err(error).context("failed to terminate managed SeaweedFS process");
|
||||
}
|
||||
}
|
||||
|
||||
let terminate_deadline = Instant::now() + DEV_SEAWEEDFS_STOP_TIMEOUT;
|
||||
while managed_seaweedfs_process_running(pid) && Instant::now() < terminate_deadline {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
if !managed_seaweedfs_process_running(pid) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let result = unsafe { libc::kill(pid as i32, libc::SIGKILL) };
|
||||
if result == -1 {
|
||||
let error = std::io::Error::last_os_error();
|
||||
if error.raw_os_error() != Some(libc::ESRCH) {
|
||||
return Err(error).context("failed to kill unresponsive managed SeaweedFS process");
|
||||
}
|
||||
}
|
||||
let kill_deadline = Instant::now() + DEV_SEAWEEDFS_KILL_TIMEOUT;
|
||||
while managed_seaweedfs_process_running(pid) && Instant::now() < kill_deadline {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
if managed_seaweedfs_process_running(pid) {
|
||||
bail!("managed SeaweedFS process {pid} survived SIGKILL");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn stop_managed_seaweedfs_process(_pid: u32) {}
|
||||
fn stop_managed_seaweedfs_process(_pid: u32) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_dev_media_path(base_url: &str, path: &str) -> Result<()> {
|
||||
let path = if path.starts_with('/') {
|
||||
|
||||
@@ -168,11 +168,11 @@ fn s3_bucket_already_exists_output(output: &str) -> bool {
|
||||
output.contains("BucketAlreadyExists") || output.contains("BucketAlreadyOwnedByYou")
|
||||
}
|
||||
|
||||
pub async fn bootstrap_schema_and_object_store() -> Result<()> {
|
||||
pub async fn bootstrap_schema() -> Result<()> {
|
||||
if cassandra_backend_enabled() {
|
||||
apply_schema(Some(config_from_env()?)).await?;
|
||||
}
|
||||
ensure_s3_buckets()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cassandra_backend_enabled() -> bool {
|
||||
|
||||
+210
-2
@@ -4,9 +4,11 @@ use crate::env::merge_default_env_with_current;
|
||||
use crate::paths::{DEV_ENV_FILE, DEV_LOCAL_ENV_FILE, ROOT, ROOT_LOCAL_ENV_FILE};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
use std::future::Future;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::process::{Child, Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::time::sleep;
|
||||
|
||||
@@ -172,6 +174,152 @@ pub fn run_command(args: &[&str], options: RunOptions<'_>) -> Result<Output> {
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub async fn run_command_interruptible(
|
||||
args: &[&str],
|
||||
options: RunOptions<'_>,
|
||||
shutdown: &mut ShutdownSignal,
|
||||
) -> Result<AwaitOutcome<Output>> {
|
||||
if options.capture {
|
||||
bail!("interruptible commands do not support captured output");
|
||||
}
|
||||
println!("$ {}", format_command(args));
|
||||
let env = merged_env(Some(&options.env), options.load_default_env)?;
|
||||
let mut command = Command::new(args[0]);
|
||||
command
|
||||
.args(&args[1..])
|
||||
.current_dir(options.cwd)
|
||||
.env_clear()
|
||||
.envs(env)
|
||||
.stdin(Stdio::inherit())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit());
|
||||
configure_process_group(&mut command);
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to run {}", format_command(args)))?;
|
||||
loop {
|
||||
let status = match child.try_wait() {
|
||||
Ok(status) => status,
|
||||
Err(error) => {
|
||||
stop_process_group(&mut child, Duration::from_secs(5)).await;
|
||||
return Err(error)
|
||||
.with_context(|| format!("failed to wait for {}", format_command(args)));
|
||||
}
|
||||
};
|
||||
if let Some(status) = status {
|
||||
stop_process_group(&mut child, Duration::from_secs(5)).await;
|
||||
let output = Output {
|
||||
status,
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
};
|
||||
if options.check && !output.status.success() {
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
bail!(
|
||||
"Command failed with exit code {code}: {}",
|
||||
format_command(args)
|
||||
);
|
||||
}
|
||||
return Ok(AwaitOutcome::Completed(output));
|
||||
}
|
||||
tokio::select! {
|
||||
signal = shutdown.recv() => {
|
||||
stop_process_group(&mut child, Duration::from_secs(5)).await;
|
||||
return Ok(AwaitOutcome::Shutdown(signal));
|
||||
}
|
||||
_ = sleep(Duration::from_millis(100)) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn configure_process_group(command: &mut Command) {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
use std::os::unix::process::CommandExt;
|
||||
command.pre_exec(|| {
|
||||
if libc::setsid() == -1 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn terminate_process_group(process: &mut Child) -> std::io::Result<()> {
|
||||
signal_process_group(process, libc::SIGTERM)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn terminate_process_group(process: &mut Child) -> std::io::Result<()> {
|
||||
process.kill()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn force_kill_process_group(process: &mut Child) {
|
||||
if let Err(error) = signal_process_group(process, libc::SIGKILL) {
|
||||
eprintln!(
|
||||
"Failed to send SIGKILL to process group {}: {error}",
|
||||
process.id()
|
||||
);
|
||||
}
|
||||
let _ = process.kill();
|
||||
let _ = process.wait();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn process_group_running(process: &mut Child) -> bool {
|
||||
if unsafe { libc::kill(-(process.id() as i32), 0) } == 0 {
|
||||
return true;
|
||||
}
|
||||
let error = std::io::Error::last_os_error();
|
||||
error.raw_os_error() != Some(libc::ESRCH)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn process_group_running(process: &mut Child) -> bool {
|
||||
process.try_wait().ok().flatten().is_none()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn force_kill_process_group(process: &mut Child) {
|
||||
let _ = process.kill();
|
||||
let _ = process.wait();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn signal_process_group(process: &Child, signal: libc::c_int) -> std::io::Result<()> {
|
||||
if unsafe { libc::kill(-(process.id() as i32), signal) } == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let error = std::io::Error::last_os_error();
|
||||
if error.raw_os_error() == Some(libc::ESRCH) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
|
||||
pub async fn stop_process_group(process: &mut Child, grace_period: Duration) {
|
||||
if let Err(error) = terminate_process_group(process) {
|
||||
eprintln!(
|
||||
"Failed to terminate process group {}: {error}",
|
||||
process.id()
|
||||
);
|
||||
}
|
||||
let deadline = Instant::now() + grace_period;
|
||||
loop {
|
||||
let leader_exited = process.try_wait().ok().flatten().is_some();
|
||||
if leader_exited && !process_group_running(process) {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
force_kill_process_group(process);
|
||||
return;
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wait_tcp(name: &str, host: &str, port: u16, timeout_secs: u64) -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
|
||||
let mut last_error = None;
|
||||
@@ -199,6 +347,19 @@ pub async fn wait_tcp(name: &str, host: &str, port: u16, timeout_secs: u64) -> R
|
||||
}
|
||||
|
||||
pub async fn wait_http(name: &str, url: &str, timeout_secs: u64) -> Result<()> {
|
||||
wait_http_status(name, url, timeout_secs, |status| status.as_u16() < 500).await
|
||||
}
|
||||
|
||||
pub async fn wait_http_success(name: &str, url: &str, timeout_secs: u64) -> Result<()> {
|
||||
wait_http_status(name, url, timeout_secs, |status| status.is_success()).await
|
||||
}
|
||||
|
||||
async fn wait_http_status(
|
||||
name: &str,
|
||||
url: &str,
|
||||
timeout_secs: u64,
|
||||
accepts: impl Fn(reqwest::StatusCode) -> bool,
|
||||
) -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()?;
|
||||
@@ -206,7 +367,7 @@ pub async fn wait_http(name: &str, url: &str, timeout_secs: u64) -> Result<()> {
|
||||
let mut last_error = None;
|
||||
while Instant::now() < deadline {
|
||||
match client.get(url).send().await {
|
||||
Ok(response) if response.status().as_u16() < 500 => {
|
||||
Ok(response) if accepts(response.status()) => {
|
||||
println!("{name} is reachable at {url}");
|
||||
return Ok(());
|
||||
}
|
||||
@@ -221,6 +382,30 @@ pub async fn wait_http(name: &str, url: &str, timeout_secs: u64) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
pub fn prefix_output(label: &str, reader: impl Read) {
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match reader.read_until(b'\n', &mut line) {
|
||||
Ok(0) => return,
|
||||
Ok(_) => {
|
||||
if line.last() == Some(&b'\n') {
|
||||
line.pop();
|
||||
}
|
||||
if line.last() == Some(&b'\r') {
|
||||
line.pop();
|
||||
}
|
||||
println!("[{label}] {}", String::from_utf8_lossy(&line));
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("[{label}] output reader failed: {error}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const RESTART_WINDOW: Duration = Duration::from_secs(60);
|
||||
pub const RESTART_LIMIT: usize = 5;
|
||||
|
||||
@@ -262,6 +447,29 @@ impl ShutdownSignal {
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AwaitOutcome<T> {
|
||||
Completed(T),
|
||||
Shutdown(&'static str),
|
||||
}
|
||||
|
||||
pub async fn await_or_shutdown<T, F>(shutdown: &mut ShutdownSignal, future: F) -> AwaitOutcome<T>
|
||||
where
|
||||
F: Future<Output = T>,
|
||||
{
|
||||
tokio::select! {
|
||||
biased;
|
||||
signal = shutdown.recv() => AwaitOutcome::Shutdown(signal),
|
||||
output = future => AwaitOutcome::Completed(output),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn pending_shutdown(shutdown: &mut ShutdownSignal) -> Option<&'static str> {
|
||||
match await_or_shutdown(shutdown, std::future::ready(())).await {
|
||||
AwaitOutcome::Completed(()) => None,
|
||||
AwaitOutcome::Shutdown(signal) => Some(signal),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub struct ShutdownSignal;
|
||||
|
||||
|
||||
+92
-14
@@ -1,6 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::manifest::{ANY_HOST, DEV_PROXY_PORT, LOCAL_APP_URL, PROXY_ROUTES, ProxyRoute};
|
||||
use crate::manifest::{
|
||||
ANY_HOST, DEV_PROXY_GATEWAY_PORTS_ENV, DEV_PROXY_PORT, GATEWAY_PORT, LOCAL_APP_URL,
|
||||
PROXY_ROUTES, ProxyRoute,
|
||||
};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use axum::{
|
||||
Router,
|
||||
@@ -14,7 +17,7 @@ use hyper_util::rt::TokioIo;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
@@ -26,6 +29,7 @@ static ROUTE_CURSORS: LazyLock<Mutex<HashMap<&'static str, usize>>> =
|
||||
#[derive(Clone)]
|
||||
struct ProxyState {
|
||||
http_client: reqwest::Client,
|
||||
gateway_ports: Arc<[u16]>,
|
||||
}
|
||||
|
||||
const BLOCKED_REQUEST_HEADERS: &[&str] = &[
|
||||
@@ -67,9 +71,21 @@ pub async fn run_proxy(host: &str, port: u16) -> Result<()> {
|
||||
.tcp_nodelay(true)
|
||||
.build()
|
||||
.context("failed to build dev proxy HTTP client")?;
|
||||
let gateway_ports = gateway_proxy_ports()?;
|
||||
println!(
|
||||
"Fluxer dev proxy gateway ports: {}",
|
||||
gateway_ports
|
||||
.iter()
|
||||
.map(u16::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
);
|
||||
let app = Router::new()
|
||||
.fallback(any(proxy_request))
|
||||
.with_state(ProxyState { http_client });
|
||||
.with_state(ProxyState {
|
||||
http_client,
|
||||
gateway_ports,
|
||||
});
|
||||
|
||||
axum::serve(
|
||||
listener,
|
||||
@@ -91,11 +107,29 @@ async fn proxy_request(
|
||||
}
|
||||
|
||||
let route = route_for_path(&request_head.path);
|
||||
let (target_host, target_port) = target_for_route(route, &state.gateway_ports);
|
||||
if is_upgrade_request(&request_head) {
|
||||
return proxy_upgrade(request, request_head, route, client_addr).await;
|
||||
return proxy_upgrade(
|
||||
request,
|
||||
request_head,
|
||||
route,
|
||||
target_host,
|
||||
target_port,
|
||||
client_addr,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
proxy_http(state, request, request_head, route, client_addr).await
|
||||
proxy_http(
|
||||
state,
|
||||
request,
|
||||
request_head,
|
||||
route,
|
||||
target_host,
|
||||
target_port,
|
||||
client_addr,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn proxy_http(
|
||||
@@ -103,9 +137,10 @@ async fn proxy_http(
|
||||
request: Request<Body>,
|
||||
request_head: RequestHead,
|
||||
route: &'static ProxyRoute,
|
||||
target_host: &'static str,
|
||||
target_port: u16,
|
||||
client_addr: SocketAddr,
|
||||
) -> Response<Body> {
|
||||
let (target_host, target_port) = target_for_route(route);
|
||||
let target_url = upstream_http_url(&request_head.path, route, target_host, target_port);
|
||||
let (parts, body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
@@ -155,10 +190,11 @@ async fn proxy_upgrade(
|
||||
mut request: Request<Body>,
|
||||
request_head: RequestHead,
|
||||
route: &'static ProxyRoute,
|
||||
target_host: &'static str,
|
||||
target_port: u16,
|
||||
client_addr: SocketAddr,
|
||||
) -> Response<Body> {
|
||||
let on_upgrade = hyper::upgrade::on(&mut request);
|
||||
let (target_host, target_port) = target_for_route(route);
|
||||
let mut target = match TcpStream::connect((target_host, target_port)).await {
|
||||
Ok(target) => target,
|
||||
Err(error) => return bad_gateway_response(error),
|
||||
@@ -497,17 +533,59 @@ pub fn route_for_path(path: &str) -> &'static ProxyRoute {
|
||||
PROXY_ROUTES.last().expect("proxy has fallback route")
|
||||
}
|
||||
|
||||
pub fn target_for_route(route: &'static ProxyRoute) -> (&'static str, u16) {
|
||||
if route.alternate_ports.is_empty() {
|
||||
pub fn target_for_route(route: &'static ProxyRoute, gateway_ports: &[u16]) -> (&'static str, u16) {
|
||||
let uses_gateway_ports = route.prefix == "/gateway";
|
||||
let port_count = if uses_gateway_ports {
|
||||
gateway_ports.len()
|
||||
} else {
|
||||
route.alternate_ports.len() + 1
|
||||
};
|
||||
assert!(
|
||||
port_count > 0,
|
||||
"proxy route must have at least one target port"
|
||||
);
|
||||
if port_count == 1 {
|
||||
if uses_gateway_ports {
|
||||
return (route.host, gateway_ports[0]);
|
||||
}
|
||||
return (route.host, route.port);
|
||||
}
|
||||
let ports = std::iter::once(route.port)
|
||||
.chain(route.alternate_ports.iter().copied())
|
||||
.collect::<Vec<_>>();
|
||||
let mut cursors = ROUTE_CURSORS.lock().expect("route cursor lock poisoned");
|
||||
let cursor = *cursors.get(route.prefix).unwrap_or(&0);
|
||||
cursors.insert(route.prefix, (cursor + 1) % ports.len());
|
||||
(route.host, ports[cursor])
|
||||
cursors.insert(route.prefix, (cursor + 1) % port_count);
|
||||
let port = if uses_gateway_ports {
|
||||
gateway_ports[cursor]
|
||||
} else if cursor == 0 {
|
||||
route.port
|
||||
} else {
|
||||
route.alternate_ports[cursor - 1]
|
||||
};
|
||||
(route.host, port)
|
||||
}
|
||||
|
||||
fn gateway_proxy_ports() -> Result<Arc<[u16]>> {
|
||||
let raw = env::var(DEV_PROXY_GATEWAY_PORTS_ENV).unwrap_or_else(|_| GATEWAY_PORT.to_string());
|
||||
let mut ports = Vec::new();
|
||||
for token in raw.split(',') {
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
bail!("{DEV_PROXY_GATEWAY_PORTS_ENV} contains an empty port");
|
||||
}
|
||||
let port = token
|
||||
.parse::<u16>()
|
||||
.with_context(|| format!("Invalid port {token:?} in {DEV_PROXY_GATEWAY_PORTS_ENV}"))?;
|
||||
if port == 0 {
|
||||
bail!("{DEV_PROXY_GATEWAY_PORTS_ENV} ports must be greater than zero");
|
||||
}
|
||||
if ports.contains(&port) {
|
||||
bail!("{DEV_PROXY_GATEWAY_PORTS_ENV} contains duplicate port {port}");
|
||||
}
|
||||
ports.push(port);
|
||||
}
|
||||
if ports.len() > 3 {
|
||||
bail!("{DEV_PROXY_GATEWAY_PORTS_ENV} supports at most three ports");
|
||||
}
|
||||
Ok(ports.into())
|
||||
}
|
||||
|
||||
pub fn rewrite_path(path: &str, route: &ProxyRoute) -> String {
|
||||
|
||||
+247
-134
@@ -1,10 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::manifest::{DEV_PROXY_PORT, MEDIA_PROXY_PORT, RustServiceSpec, rust_services};
|
||||
use crate::paths::ROOT;
|
||||
use crate::paths::{ROOT, TARGET_DIR};
|
||||
use crate::proc::{
|
||||
RESTART_LIMIT, RESTART_WINDOW, RunOptions, ShutdownSignal, format_command, merged_env,
|
||||
restart_budget_exceeded, run_command,
|
||||
AwaitOutcome, RESTART_LIMIT, RESTART_WINDOW, RunOptions, ShutdownSignal, await_or_shutdown,
|
||||
configure_process_group, format_command, merged_env, pending_shutdown, prefix_output,
|
||||
restart_budget_exceeded, run_command_interruptible,
|
||||
};
|
||||
use anyhow::{Result, bail};
|
||||
use std::collections::{BTreeSet, VecDeque};
|
||||
@@ -12,6 +13,8 @@ use std::env;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::fs;
|
||||
use std::net::{SocketAddr, TcpListener};
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::time::sleep;
|
||||
@@ -34,26 +37,51 @@ impl SupervisedService {
|
||||
|
||||
pub async fn run_rust_services(service_names: &[String]) -> Result<i32> {
|
||||
let selected = select_services(service_names)?;
|
||||
cleanup_orphaned_service_processes(&selected).await?;
|
||||
wait_for_service_ports_available(&selected)?;
|
||||
build_services(&selected)?;
|
||||
let mut shutdown = ShutdownSignal::new()?;
|
||||
cleanup_orphaned_service_processes(&selected).await?;
|
||||
if let Some(signal) = pending_shutdown(&mut shutdown).await {
|
||||
println!("Received {signal}; stopping Rust service startup...");
|
||||
return Ok(0);
|
||||
}
|
||||
wait_for_service_ports_available(&selected)?;
|
||||
match build_services(&selected, &mut shutdown).await? {
|
||||
AwaitOutcome::Completed(_) => {}
|
||||
AwaitOutcome::Shutdown(signal) => {
|
||||
println!("Received {signal}; stopping Rust service startup...");
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
let mut supervised = Vec::new();
|
||||
for spec in &selected {
|
||||
for (mode, port) in [("router", spec.port_base), ("shard", spec.port_base + 1)] {
|
||||
let child = match start_service(spec, mode, port) {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
stop_supervised_services(&mut supervised);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
supervised.push(SupervisedService {
|
||||
spec: spec.clone(),
|
||||
mode,
|
||||
port,
|
||||
child: start_service(spec, mode, port)?,
|
||||
child,
|
||||
restarts: VecDeque::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
loop {
|
||||
if let Err(error) = restart_exited_services(&mut supervised).await {
|
||||
stop_supervised_services(&mut supervised);
|
||||
return Err(error);
|
||||
match await_or_shutdown(&mut shutdown, restart_exited_services(&mut supervised)).await {
|
||||
AwaitOutcome::Completed(Ok(())) => {}
|
||||
AwaitOutcome::Completed(Err(error)) => {
|
||||
stop_supervised_services(&mut supervised);
|
||||
return Err(error);
|
||||
}
|
||||
AwaitOutcome::Shutdown(signal) => {
|
||||
println!("Received {signal}; stopping Rust service tasks...");
|
||||
stop_supervised_services(&mut supervised);
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
tokio::select! {
|
||||
signal = shutdown.recv() => {
|
||||
@@ -71,6 +99,7 @@ async fn restart_exited_services(supervised: &mut [SupervisedService]) -> Result
|
||||
let Some(status) = entry.child.try_wait()? else {
|
||||
continue;
|
||||
};
|
||||
crate::gateway::stop_child_processes(&mut [&mut entry.child]);
|
||||
if restart_budget_exceeded(&mut entry.restarts, Instant::now()) {
|
||||
bail!(
|
||||
"Rust service {} exited with {status} after {RESTART_LIMIT} restarts within {}s; giving up",
|
||||
@@ -145,7 +174,10 @@ pub fn select_services(service_names: &[String]) -> Result<Vec<RustServiceSpec>>
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn build_services(services: &[RustServiceSpec]) -> Result<()> {
|
||||
async fn build_services(
|
||||
services: &[RustServiceSpec],
|
||||
shutdown: &mut ShutdownSignal,
|
||||
) -> Result<AwaitOutcome<std::process::Output>> {
|
||||
let mut packages = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
for spec in services {
|
||||
@@ -156,7 +188,23 @@ fn build_services(services: &[RustServiceSpec]) -> Result<()> {
|
||||
let mut args = vec!["cargo".to_owned(), "build".to_owned()];
|
||||
args.extend(packages);
|
||||
let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
run_command(&refs, RunOptions::default()).map(drop)
|
||||
let env = if env::var_os("CARGO_BUILD_JOBS").is_none() {
|
||||
vec![(
|
||||
"CARGO_BUILD_JOBS".to_owned(),
|
||||
Some(env::var("FLUXER_DEV_CARGO_JOBS").unwrap_or_else(|_| "2".to_owned())),
|
||||
)]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
run_command_interruptible(
|
||||
&refs,
|
||||
RunOptions {
|
||||
env,
|
||||
..RunOptions::default()
|
||||
},
|
||||
shutdown,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn start_service(spec: &RustServiceSpec, mode: &str, port: u16) -> Result<Child> {
|
||||
@@ -172,14 +220,7 @@ fn start_service(spec: &RustServiceSpec, mode: &str, port: u16) -> Result<Child>
|
||||
.envs(env)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
use std::os::unix::process::CommandExt;
|
||||
command.pre_exec(|| {
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
configure_process_group(&mut command);
|
||||
let mut child = command.spawn()?;
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let stdout_label = label.clone();
|
||||
@@ -212,11 +253,20 @@ pub fn service_command(spec: &RustServiceSpec) -> Vec<String> {
|
||||
format!("run -p {}", spec.package),
|
||||
];
|
||||
}
|
||||
let target_dir = env::var_os("CARGO_TARGET_DIR")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| TARGET_DIR.clone());
|
||||
let target_dir = if target_dir.is_absolute() {
|
||||
target_dir
|
||||
} else {
|
||||
ROOT.join(target_dir)
|
||||
};
|
||||
vec![
|
||||
"cargo".to_owned(),
|
||||
"run".to_owned(),
|
||||
"-p".to_owned(),
|
||||
spec.package.to_owned(),
|
||||
target_dir
|
||||
.join("debug")
|
||||
.join(format!("{}{}", spec.package, std::env::consts::EXE_SUFFIX))
|
||||
.display()
|
||||
.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -238,18 +288,6 @@ pub fn service_env(spec: &RustServiceSpec, mode: &str, port: u16) -> Vec<(String
|
||||
.unwrap_or_else(|_| "nats://nats:4222".to_owned()),
|
||||
),
|
||||
),
|
||||
(
|
||||
"FLUXER_CASSANDRA_HOSTS".to_owned(),
|
||||
Some(env::var("FLUXER_CASSANDRA_HOSTS").unwrap_or_else(|_| "cassandra".to_owned())),
|
||||
),
|
||||
(
|
||||
"FLUXER_CASSANDRA_KEYSPACE".to_owned(),
|
||||
Some(env::var("FLUXER_CASSANDRA_KEYSPACE").unwrap_or_else(|_| "fluxer".to_owned())),
|
||||
),
|
||||
(
|
||||
"FLUXER_CASSANDRA_PORT".to_owned(),
|
||||
Some(env::var("FLUXER_CASSANDRA_PORT").unwrap_or_else(|_| "9042".to_owned())),
|
||||
),
|
||||
];
|
||||
if mode == "shard" {
|
||||
envs.push(("FLUXER_SVC_SHARD_ID".to_owned(), Some("0".to_owned())));
|
||||
@@ -284,13 +322,6 @@ pub fn service_env(spec: &RustServiceSpec, mode: &str, port: u16) -> Vec<(String
|
||||
envs
|
||||
}
|
||||
|
||||
fn prefix_output(label: &str, reader: impl std::io::Read) {
|
||||
use std::io::{BufRead, BufReader};
|
||||
for line in BufReader::new(reader).lines().map_while(|line| line.ok()) {
|
||||
println!("[{label}] {line}");
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_service_ports_available(services: &[RustServiceSpec]) -> Result<()> {
|
||||
let conflicts = services
|
||||
.iter()
|
||||
@@ -333,7 +364,7 @@ async fn cleanup_orphaned_service_processes(services: &[RustServiceSpec]) -> Res
|
||||
"Stopping orphaned Rust service process group(s): {}",
|
||||
format_pids(&pids)
|
||||
);
|
||||
let term_failed = signal_process_groups(&pids, libc::SIGTERM);
|
||||
let term_failed = signal_process_groups(&leaders, libc::SIGTERM);
|
||||
if !term_failed.is_empty() {
|
||||
println!(
|
||||
"SIGTERM delivery failed for orphaned Rust service pid(s): {}",
|
||||
@@ -348,7 +379,12 @@ async fn cleanup_orphaned_service_processes(services: &[RustServiceSpec]) -> Res
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let kill_failed = signal_process_groups(&remaining, libc::SIGKILL);
|
||||
let remaining_leaders = leaders
|
||||
.iter()
|
||||
.filter(|leader| remaining.contains(&leader.pid))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let kill_failed = signal_process_groups(&remaining_leaders, libc::SIGKILL);
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
let survivors = surviving_service_group_pids(&leaders)?;
|
||||
if survivors.is_empty() {
|
||||
@@ -380,19 +416,85 @@ async fn cleanup_orphaned_service_processes(_services: &[RustServiceSpec]) -> Re
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct RustServiceLeader {
|
||||
pid: i32,
|
||||
members: Vec<RustServiceProcessIdentity>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct RustServiceProcessIdentity {
|
||||
pid: i32,
|
||||
starttime: u64,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct RustServiceProcess {
|
||||
pid: i32,
|
||||
ppid: i32,
|
||||
pgid: i32,
|
||||
state: char,
|
||||
starttime: u64,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn orphaned_service_leaders(services: &[RustServiceSpec]) -> Result<Vec<RustServiceLeader>> {
|
||||
let binaries = services
|
||||
let current_exe = std::env::current_exe()?;
|
||||
let processes = service_process_snapshot()?;
|
||||
let mut owned_pids = BTreeSet::new();
|
||||
for process in &processes {
|
||||
if process.ppid != 1 || proc_stat_state_is_dead(process.state) {
|
||||
continue;
|
||||
}
|
||||
let args = proc_cmdline(process.pid);
|
||||
let orphaned_supervisor = cmdline_is_service_supervisor(&args, ¤t_exe);
|
||||
let orphaned_service = proc_has_managed_service_environment(process.pid, services)
|
||||
&& fs::read_link(format!("/proc/{}/cwd", process.pid))
|
||||
.ok()
|
||||
.as_deref()
|
||||
== Some(ROOT.as_path());
|
||||
if orphaned_supervisor || orphaned_service {
|
||||
owned_pids.insert(process.pid);
|
||||
}
|
||||
}
|
||||
loop {
|
||||
let previous_count = owned_pids.len();
|
||||
for process in &processes {
|
||||
if owned_pids.contains(&process.ppid) {
|
||||
owned_pids.insert(process.pid);
|
||||
}
|
||||
}
|
||||
if owned_pids.len() == previous_count {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let group_pids = processes
|
||||
.iter()
|
||||
.map(|spec| format!("target/debug/{}", spec.package))
|
||||
.filter(|process| owned_pids.contains(&process.pid) && process.pgid > 1)
|
||||
.map(|process| process.pgid)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut leaders = Vec::new();
|
||||
let leaders = group_pids
|
||||
.into_iter()
|
||||
.map(|pid| RustServiceLeader {
|
||||
pid,
|
||||
members: processes
|
||||
.iter()
|
||||
.filter(|process| owned_pids.contains(&process.pid) && process.pgid == pid)
|
||||
.map(|process| RustServiceProcessIdentity {
|
||||
pid: process.pid,
|
||||
starttime: process.starttime,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(leaders)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn service_process_snapshot() -> Result<Vec<RustServiceProcess>> {
|
||||
let mut processes = Vec::new();
|
||||
for entry in fs::read_dir("/proc")? {
|
||||
let Ok(entry) = entry else {
|
||||
continue;
|
||||
@@ -404,18 +506,69 @@ fn orphaned_service_leaders(services: &[RustServiceSpec]) -> Result<Vec<RustServ
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if proc_parent_pid(pid) != Some(1) {
|
||||
let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) else {
|
||||
continue;
|
||||
}
|
||||
if !cmdline_has_service_binary(&proc_cmdline(pid), &binaries) {
|
||||
};
|
||||
let Some((state, ppid, pgid, starttime)) = parse_proc_stat_process(&stat) else {
|
||||
continue;
|
||||
}
|
||||
if let Some(starttime) = proc_stat_starttime(pid) {
|
||||
leaders.push(RustServiceLeader { pid, starttime });
|
||||
}
|
||||
};
|
||||
processes.push(RustServiceProcess {
|
||||
pid,
|
||||
ppid,
|
||||
pgid,
|
||||
state,
|
||||
starttime,
|
||||
});
|
||||
}
|
||||
leaders.sort_unstable_by_key(|leader| leader.pid);
|
||||
Ok(leaders)
|
||||
Ok(processes)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cmdline_is_service_supervisor(args: &[String], current_exe: &Path) -> bool {
|
||||
args.first().map(Path::new) == Some(current_exe)
|
||||
&& args.get(1).map(String::as_str) == Some("rust-services")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn proc_has_managed_service_environment(pid: i32, services: &[RustServiceSpec]) -> bool {
|
||||
let environment_bytes = fs::read(format!("/proc/{pid}/environ")).unwrap_or_default();
|
||||
let environment = environment_bytes
|
||||
.split(|byte| *byte == 0)
|
||||
.filter_map(|entry| {
|
||||
let separator = entry.iter().position(|byte| *byte == b'=')?;
|
||||
let (key, value) = entry.split_at(separator);
|
||||
Some((key, &value[1..]))
|
||||
})
|
||||
.filter_map(|(key, value)| {
|
||||
Some((
|
||||
std::str::from_utf8(key).ok()?,
|
||||
std::str::from_utf8(value).ok()?,
|
||||
))
|
||||
})
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
let Some(name) = environment.get("FLUXER_SVC_NAME") else {
|
||||
return false;
|
||||
};
|
||||
let Some(mode) = environment.get("FLUXER_SVC_MODE") else {
|
||||
return false;
|
||||
};
|
||||
let Some(port) = environment
|
||||
.get("FLUXER_SVC_PORT")
|
||||
.and_then(|port| port.parse::<u16>().ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if environment.get("FLUXER_SVC_LISTEN_HOST") != Some(&"0.0.0.0") {
|
||||
return false;
|
||||
}
|
||||
services.iter().any(|service| {
|
||||
service.name == *name
|
||||
&& match *mode {
|
||||
"router" => port == service.port_base,
|
||||
"shard" => port == service.port_base + 1,
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -429,7 +582,7 @@ fn format_pids(pids: &[i32]) -> String {
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[cfg(test)]
|
||||
fn cmdline_has_service_binary(args: &[String], binaries: &BTreeSet<String>) -> bool {
|
||||
args.first()
|
||||
.map(|arg| binaries.iter().any(|binary| arg.ends_with(binary)))
|
||||
@@ -446,30 +599,6 @@ fn proc_cmdline(pid: i32) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn proc_parent_pid(pid: i32) -> Option<i32> {
|
||||
fs::read_to_string(format!("/proc/{pid}/status"))
|
||||
.ok()?
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("PPid:")?.trim().parse().ok())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn process_exists(pid: i32) -> bool {
|
||||
assert!(pid > 0);
|
||||
match proc_stat_state_and_pgid(pid) {
|
||||
Some((state, _pgid)) => !proc_stat_state_is_dead(state),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn proc_stat_state_and_pgid(pid: i32) -> Option<(char, i32)> {
|
||||
assert!(pid > 0);
|
||||
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
parse_proc_stat_state_and_pgid(&stat)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn parse_proc_stat_state_and_pgid(stat: &str) -> Option<(char, i32)> {
|
||||
let (_, after_comm) = stat.rsplit_once(')')?;
|
||||
@@ -481,10 +610,13 @@ fn parse_proc_stat_state_and_pgid(stat: &str) -> Option<(char, i32)> {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn proc_stat_starttime(pid: i32) -> Option<u64> {
|
||||
assert!(pid > 0);
|
||||
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
parse_proc_stat_starttime(&stat)
|
||||
fn parse_proc_stat_process(stat: &str) -> Option<(char, i32, i32, u64)> {
|
||||
let (_, after_comm) = stat.rsplit_once(')')?;
|
||||
let mut fields = after_comm.split_ascii_whitespace();
|
||||
let state = fields.next()?.chars().next()?;
|
||||
let ppid = fields.next()?.parse().ok()?;
|
||||
let pgid = fields.next()?.parse().ok()?;
|
||||
Some((state, ppid, pgid, parse_proc_stat_starttime(stat)?))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
@@ -500,62 +632,43 @@ fn proc_stat_state_is_dead(state: char) -> bool {
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn surviving_service_group_pids(leaders: &[RustServiceLeader]) -> Result<Vec<i32>> {
|
||||
let leader_pids = leaders
|
||||
Ok(leaders
|
||||
.iter()
|
||||
.filter(|leader| service_group_has_owned_member(leader))
|
||||
.map(|leader| leader.pid)
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
let mut survivors = Vec::new();
|
||||
for entry in fs::read_dir("/proc")? {
|
||||
let Ok(entry) = entry else {
|
||||
continue;
|
||||
};
|
||||
let Some(pid) = entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.and_then(|name| name.parse::<i32>().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) else {
|
||||
continue;
|
||||
};
|
||||
let Some((state, pgid)) = parse_proc_stat_state_and_pgid(&stat) else {
|
||||
continue;
|
||||
};
|
||||
if proc_stat_state_is_dead(state) {
|
||||
continue;
|
||||
}
|
||||
if leader_pids.contains(&pgid) {
|
||||
survivors.push(pid);
|
||||
continue;
|
||||
}
|
||||
let leader = leaders.iter().find(|leader| leader.pid == pid);
|
||||
if let Some(leader) = leader
|
||||
&& parse_proc_stat_starttime(&stat) == Some(leader.starttime)
|
||||
{
|
||||
survivors.push(pid);
|
||||
}
|
||||
}
|
||||
survivors.sort_unstable();
|
||||
Ok(survivors)
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn signal_process_groups(pids: &[i32], signal: i32) -> Vec<i32> {
|
||||
fn service_group_has_owned_member(leader: &RustServiceLeader) -> bool {
|
||||
leader.members.iter().any(|member| {
|
||||
let Ok(stat) = fs::read_to_string(format!("/proc/{}/stat", member.pid)) else {
|
||||
return false;
|
||||
};
|
||||
let Some((state, pgid)) = parse_proc_stat_state_and_pgid(&stat) else {
|
||||
return false;
|
||||
};
|
||||
!proc_stat_state_is_dead(state)
|
||||
&& pgid == leader.pid
|
||||
&& parse_proc_stat_starttime(&stat) == Some(member.starttime)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn signal_process_groups(leaders: &[RustServiceLeader], signal: i32) -> Vec<i32> {
|
||||
assert!(signal == libc::SIGTERM || signal == libc::SIGKILL);
|
||||
let mut failed = Vec::with_capacity(pids.len());
|
||||
for pid in pids {
|
||||
assert!(*pid > 0);
|
||||
let group_result = unsafe { libc::kill(-pid, signal) };
|
||||
let mut failed = Vec::with_capacity(leaders.len());
|
||||
for leader in leaders {
|
||||
assert!(leader.pid > 1);
|
||||
if !service_group_has_owned_member(leader) {
|
||||
continue;
|
||||
}
|
||||
let group_result = unsafe { libc::kill(-leader.pid, signal) };
|
||||
if group_result == 0 {
|
||||
continue;
|
||||
}
|
||||
let direct_result = unsafe { libc::kill(*pid, signal) };
|
||||
if direct_result == 0 {
|
||||
continue;
|
||||
}
|
||||
if process_exists(*pid) {
|
||||
failed.push(*pid);
|
||||
if service_group_has_owned_member(leader) {
|
||||
failed.push(leader.pid);
|
||||
}
|
||||
}
|
||||
failed
|
||||
|
||||
+11
-5
@@ -253,6 +253,16 @@ pub async fn run_cloudflare_tunnel(
|
||||
return Ok(status.code().unwrap_or(1));
|
||||
}
|
||||
if running_inside_devcontainer() {
|
||||
let container = std::fs::read_to_string("/etc/hostname")
|
||||
.context("failed to read the current devcontainer hostname")?;
|
||||
let container = container.trim();
|
||||
if container.is_empty()
|
||||
|| !container
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
|
||||
{
|
||||
bail!("Current devcontainer hostname is invalid");
|
||||
}
|
||||
let docker = docker_command();
|
||||
let mut command = Command::new(&docker[0]);
|
||||
command.args(&docker[1..]);
|
||||
@@ -261,11 +271,7 @@ pub async fn run_cloudflare_tunnel(
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
&format!(
|
||||
"container:{}",
|
||||
std::env::var("HOSTNAME")
|
||||
.unwrap_or_else(|_| "fluxer-dev-workspace-1".to_owned())
|
||||
),
|
||||
&format!("container:{container}"),
|
||||
"cloudflare/cloudflared:latest",
|
||||
"tunnel",
|
||||
"run",
|
||||
|
||||
Reference in New Issue
Block a user