Compare commits

..

5 Commits

Author SHA1 Message Date
安正超
e9c9a2d1f2 fix: simplify Docker entrypoint following efficient user switching pattern (#421)
* fix: simplify Docker entrypoint following efficient user switching pattern

- Remove ALL file permission modifications (no chown at all)
- Use chroot --userspec or gosu to switch user context
- Extremely simple and fast implementation
- Zero filesystem modifications for permissions

Fixes #388

* Update entrypoint.sh

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update entrypoint.sh

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update entrypoint.sh

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* wip

* wip

* wip

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-19 22:58:54 +08:00
0xdx2
3ebab98d2d feat: include user-defined metadata in S3 response (#431) 2025-08-19 22:09:50 +08:00
weisd
10c949af62 fix:make bucket exists (#428) 2025-08-19 16:14:59 +08:00
reigadegr
4a3325276d fix(ecstore): add async-recursion to resolve nightly trait solver reg… (#415)
* fix(ecstore): add async-recursion to resolve nightly trait solver regression

The newest nightly compiler switched to the new trait solver, which
currently rejects async recursive functions that were previously accepted.
This causes the following compilation failures:

- `LocalDisk::delete_file()`
- `LocalDisk::scan_dir()`

Add `async-recursion` as a workspace dependency and annotate both functions with `#[async_recursion]` so that the crate compiles cleanly with the latest nightly and will continue to build once the new solver lands in stable.

Signed-off-by: reigadegr <2722688642@qq.com>

* fix: resolve duplicate bound error in scan_dir function

Replaced inline trait bounds with where clause to avoid duplication caused by macro expansion.

Signed-off-by: reigadegr <2722688642@qq.com>

---------

Signed-off-by: reigadegr <2722688642@qq.com>
Co-authored-by: 安正超 <anzhengchao@gmail.com>
2025-08-18 20:58:05 +08:00
majinghe
c5f6c66f72 feat: extend rustfs mcp with bucket creation and deletion (#416)
* feat: extend rustfs mcp with bucket creation and deletion

* update file to fix pipeline error

* change variable name to fix pipeline error
2025-08-18 09:06:55 +08:00
13 changed files with 400 additions and 275 deletions

1
Cargo.lock generated
View File

@@ -8205,6 +8205,7 @@ name = "rustfs-ecstore"
version = "0.0.5"
dependencies = [
"async-channel",
"async-recursion",
"async-trait",
"aws-sdk-s3",
"base64 0.22.1",

View File

@@ -1,58 +1,56 @@
# Multi-stage build for RustFS production image
# Build stage: Download and extract RustFS binary
# -------------------
# Build stage
# -------------------
FROM alpine:3.22 AS build
# Build arguments for platform and release
ARG TARGETARCH
ARG RELEASE=latest
# Install minimal dependencies for downloading and extracting
RUN apk add --no-cache ca-certificates curl unzip
# Create build directory
WORKDIR /build
# Set architecture-specific variables
RUN if [ "$TARGETARCH" = "amd64" ]; then \
echo "x86_64-musl" > /tmp/arch; \
elif [ "$TARGETARCH" = "arm64" ]; then \
echo "aarch64-musl" > /tmp/arch; \
# Download and extract release package matching current TARGETARCH
# - If RELEASE=latest: take first tag_name from /releases (may include pre-releases)
# - Otherwise use specified tag (e.g. v0.1.2)
RUN set -eux; \
case "$TARGETARCH" in \
amd64) ARCH_SUBSTR="x86_64-musl" ;; \
arm64) ARCH_SUBSTR="aarch64-musl" ;; \
*) echo "Unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \
esac; \
if [ "$RELEASE" = "latest" ]; then \
TAG="$(curl -fsSL https://api.github.com/repos/rustfs/rustfs/releases \
| grep -o '"tag_name": "[^"]*"' | cut -d'"' -f4 | head -n 1)"; \
else \
echo "unsupported" > /tmp/arch; \
fi
RUN ARCH=$(cat /tmp/arch) && \
if [ "$ARCH" = "unsupported" ]; then \
echo "Unsupported architecture: $TARGETARCH" && exit 1; \
fi && \
if [ "${RELEASE}" = "latest" ]; then \
# For latest, download from GitHub releases using the -latest suffix
PACKAGE_NAME="rustfs-linux-${ARCH}-latest.zip"; \
# Use GitHub API to get the latest release URL
LATEST_RELEASE_URL=$(curl -s https://api.github.com/repos/rustfs/rustfs/releases/latest | grep -o '"browser_download_url": "[^"]*'"${PACKAGE_NAME}"'"' | cut -d'"' -f4 | head -1); \
if [ -z "$LATEST_RELEASE_URL" ]; then \
echo "Failed to find latest release for ${PACKAGE_NAME}" >&2; \
exit 1; \
fi; \
DOWNLOAD_URL="$LATEST_RELEASE_URL"; \
else \
# For specific versions, construct the GitHub release URL directly
# RELEASE is the GitHub release tag (e.g., "1.0.0-alpha.42")
# VERSION is the version in filename (e.g., "v1.0.0-alpha.42")
VERSION="v${RELEASE}"; \
PACKAGE_NAME="rustfs-linux-${ARCH}-${VERSION}.zip"; \
DOWNLOAD_URL="https://github.com/rustfs/rustfs/releases/download/${RELEASE}/${PACKAGE_NAME}"; \
fi && \
echo "Downloading ${PACKAGE_NAME} from ${DOWNLOAD_URL}" >&2 && \
curl -f -L "${DOWNLOAD_URL}" -o rustfs.zip && \
unzip rustfs.zip -d /build && \
chmod +x /build/rustfs && \
rm rustfs.zip || { echo "Failed to download or extract ${PACKAGE_NAME}" >&2; exit 1; }
TAG="$RELEASE"; \
fi; \
echo "Using tag: $TAG (arch pattern: $ARCH_SUBSTR)"; \
# Find download URL in assets list for this tag that contains arch substring and ends with .zip
URL="$(curl -fsSL "https://api.github.com/repos/rustfs/rustfs/releases/tags/$TAG" \
| grep -o "\"browser_download_url\": \"[^\"]*${ARCH_SUBSTR}[^\"]*\\.zip\"" \
| cut -d'"' -f4 | head -n 1)"; \
if [ -z "$URL" ]; then echo "Failed to locate release asset for $ARCH_SUBSTR at tag $TAG" >&2; exit 1; fi; \
echo "Downloading: $URL"; \
curl -fL "$URL" -o rustfs.zip; \
unzip -q rustfs.zip -d /build; \
# If binary is not in root directory, try to locate and move from zip to /build/rustfs
if [ ! -x /build/rustfs ]; then \
BIN_PATH="$(unzip -Z -1 rustfs.zip | grep -E '(^|/)rustfs$' | head -n 1 || true)"; \
if [ -n "$BIN_PATH" ]; then \
mkdir -p /build/.tmp && unzip -q rustfs.zip "$BIN_PATH" -d /build/.tmp && \
mv "/build/.tmp/$BIN_PATH" /build/rustfs; \
fi; \
fi; \
[ -x /build/rustfs ] || { echo "rustfs binary not found in asset" >&2; exit 1; }; \
chmod +x /build/rustfs; \
rm -rf rustfs.zip /build/.tmp || true
# Runtime stage: Configure runtime environment
FROM alpine:3.22.1
# Build arguments and labels
# -------------------
# Runtime stage
# -------------------
FROM alpine:3.22
ARG RELEASE=latest
ARG BUILD_DATE
ARG VCS_REF
@@ -60,7 +58,7 @@ ARG VCS_REF
LABEL name="RustFS" \
vendor="RustFS Team" \
maintainer="RustFS Team <dev@rustfs.com>" \
version="${RELEASE}" \
version="v${RELEASE#v}" \
release="${RELEASE}" \
build-date="${BUILD_DATE}" \
vcs-ref="${VCS_REF}" \
@@ -69,43 +67,37 @@ LABEL name="RustFS" \
url="https://rustfs.com" \
license="Apache-2.0"
# Install runtime dependencies
RUN echo "https://dl-cdn.alpinelinux.org/alpine/v3.20/community" >> /etc/apk/repositories && \
apk update && \
apk add --no-cache ca-certificates bash gosu coreutils shadow && \
# Install only runtime requirements: certificates and coreutils (provides chroot --userspec)
RUN apk add --no-cache ca-certificates coreutils && \
addgroup -g 1000 rustfs && \
adduser -u 1000 -G rustfs -s /bin/bash -D rustfs
adduser -u 1000 -G rustfs -s /sbin/nologin -D rustfs
# Copy CA certificates and RustFS binary from build stage
# Copy binary and entry script (ensure fixed entrypoint.sh exists in repository)
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /build/rustfs /usr/bin/rustfs
# Copy entry point script
COPY entrypoint.sh /entrypoint.sh
# Set permissions
RUN chmod +x /usr/bin/rustfs /entrypoint.sh && \
mkdir -p /data /logs && \
chown rustfs:rustfs /data /logs && \
chmod 700 /data /logs
chmod 0750 /data /logs
# Environment variables (credentials should be set via environment or secrets)
ENV RUSTFS_ADDRESS=:9000 \
RUSTFS_ACCESS_KEY=rustfsadmin \
RUSTFS_SECRET_KEY=rustfsadmin \
RUSTFS_CONSOLE_ENABLE=true \
RUSTFS_VOLUMES=/data \
RUST_LOG=warn \
RUSTFS_OBS_LOG_DIRECTORY=/logs \
RUSTFS_SINKS_FILE_PATH=/logs
# Default environment (can be overridden in docker run/compose)
ENV RUSTFS_ADDRESS=":9000" \
RUSTFS_ACCESS_KEY="rustfsadmin" \
RUSTFS_SECRET_KEY="rustfsadmin" \
RUSTFS_CONSOLE_ENABLE="true" \
RUSTFS_VOLUMES="/data" \
RUST_LOG="warn" \
RUSTFS_OBS_LOG_DIRECTORY="/logs" \
RUSTFS_SINKS_FILE_PATH="/logs" \
RUSTFS_USERNAME="rustfs" \
RUSTFS_GROUPNAME="rustfs" \
RUSTFS_UID="1000" \
RUSTFS_GID="1000"
# Expose port
EXPOSE 9000
# Volumes for data and logs
VOLUME ["/data", "/logs"]
# Set entry point
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/usr/bin/rustfs"]

View File

@@ -1,80 +1,88 @@
# syntax=docker/dockerfile:1.6
# Multi-stage Dockerfile for RustFS - LOCAL DEVELOPMENT ONLY
#
# ⚠️ IMPORTANT: This Dockerfile is for local development and testing only.
# ⚠️ It builds RustFS from source code and is NOT used in CI/CD pipelines.
# ⚠️ CI/CD pipeline uses pre-built binaries from Dockerfile instead.
# IMPORTANT: This Dockerfile builds RustFS from source for local development and testing.
# CI/CD uses the production Dockerfile with prebuilt binaries instead.
#
# Usage for local development:
# Example:
# docker build -f Dockerfile.source -t rustfs:dev-local .
# docker run --rm -p 9000:9000 rustfs:dev-local
#
# Supports cross-compilation for amd64 and arm64 architectures
# Supports cross-compilation for amd64 and arm64 via TARGETPLATFORM.
ARG TARGETPLATFORM
ARG BUILDPLATFORM
# -----------------------------
# Build stage
FROM --platform=$BUILDPLATFORM rust:1.88-bookworm AS builder
# -----------------------------
FROM rust:1.88-bookworm AS builder
# Re-declare build arguments after FROM (required for multi-stage builds)
# Re-declare args after FROM
ARG TARGETPLATFORM
ARG BUILDPLATFORM
# Debug: Print platform information
RUN echo "🐳 Build Info: BUILDPLATFORM=$BUILDPLATFORM, TARGETPLATFORM=$TARGETPLATFORM"
# Debug: print platforms
RUN echo "Build info -> BUILDPLATFORM=${BUILDPLATFORM}, TARGETPLATFORM=${TARGETPLATFORM}"
# Install required build dependencies
RUN apt-get update && apt-get install -y \
wget \
git \
# Install build toolchain and headers
# Use distro packages for protoc/flatc to avoid host-arch mismatch
RUN set -eux; \
export DEBIAN_FRONTEND=noninteractive; \
apt-get update; \
apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
curl \
unzip \
gcc \
git \
pkg-config \
libssl-dev \
lld \
&& rm -rf /var/lib/apt/lists/*
protobuf-compiler \
flatbuffers-compiler; \
rm -rf /var/lib/apt/lists/*
# Note: sccache removed for simpler builds
# Install cross-compilation tools for ARM64
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
apt-get update && \
apt-get install -y gcc-aarch64-linux-gnu && \
rm -rf /var/lib/apt/lists/*; \
# Optional: cross toolchain for aarch64 (only when targeting linux/arm64)
RUN set -eux; \
if [ "${TARGETPLATFORM:-linux/amd64}" = "linux/arm64" ]; then \
export DEBIAN_FRONTEND=noninteractive; \
apt-get update; \
apt-get install -y --no-install-recommends gcc-aarch64-linux-gnu; \
rm -rf /var/lib/apt/lists/*; \
fi
# Install protoc
RUN wget https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-linux-x86_64.zip \
&& unzip protoc-31.1-linux-x86_64.zip -d protoc3 \
&& mv protoc3/bin/* /usr/local/bin/ && chmod +x /usr/local/bin/protoc \
&& mv protoc3/include/* /usr/local/include/ && rm -rf protoc-31.1-linux-x86_64.zip protoc3
# Install flatc
RUN wget https://github.com/google/flatbuffers/releases/download/v25.2.10/Linux.flatc.binary.g++-13.zip \
&& unzip Linux.flatc.binary.g++-13.zip \
&& mv flatc /usr/local/bin/ && chmod +x /usr/local/bin/flatc && rm -rf Linux.flatc.binary.g++-13.zip
# Set up Rust targets based on platform
RUN set -e && \
PLATFORM="${TARGETPLATFORM:-linux/amd64}" && \
echo "🎯 Setting up Rust target for platform: $PLATFORM" && \
case "$PLATFORM" in \
"linux/amd64") rustup target add x86_64-unknown-linux-gnu ;; \
"linux/arm64") rustup target add aarch64-unknown-linux-gnu ;; \
*) echo "❌ Unsupported platform: $PLATFORM" && exit 1 ;; \
# Add Rust targets based on TARGETPLATFORM
RUN set -eux; \
case "${TARGETPLATFORM:-linux/amd64}" in \
linux/amd64) rustup target add x86_64-unknown-linux-gnu ;; \
linux/arm64) rustup target add aarch64-unknown-linux-gnu ;; \
*) echo "Unsupported TARGETPLATFORM=${TARGETPLATFORM}" >&2; exit 1 ;; \
esac
# Set up environment for cross-compilation
# Cross-compilation environment (used only when targeting aarch64)
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
ENV CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc
ENV CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++
WORKDIR /usr/src/rustfs
# Copy all source code
# Layered copy to maximize caching:
# 1) top-level manifests
COPY Cargo.toml Cargo.lock ./
# 2) workspace member manifests (adjust if workspace layout changes)
COPY rustfs/Cargo.toml rustfs/Cargo.toml
COPY crates/*/Cargo.toml crates/
COPY cli/rustfs-gui/Cargo.toml cli/rustfs-gui/Cargo.toml
# Pre-fetch dependencies for better caching
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
cargo fetch --locked || true
# 3) copy full sources (this is the main cache invalidation point)
COPY . .
# Configure cargo for optimized builds
# Cargo build configuration for lean release artifacts
ENV CARGO_NET_GIT_FETCH_WITH_CLI=true \
CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
CARGO_INCREMENTAL=0 \
@@ -82,75 +90,92 @@ ENV CARGO_NET_GIT_FETCH_WITH_CLI=true \
CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO=off \
CARGO_PROFILE_RELEASE_STRIP=symbols
# Generate protobuf code
RUN cargo run --bin gproto
# Generate protobuf/flatbuffers code (uses protoc/flatc from distro)
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/usr/src/rustfs/target \
cargo run --bin gproto
# Build the actual application with optimizations
RUN case "$TARGETPLATFORM" in \
"linux/amd64") \
echo "🔨 Building for amd64..." && \
rustup target add x86_64-unknown-linux-gnu && \
cargo build --release --target x86_64-unknown-linux-gnu --bin rustfs -j $(nproc) && \
cp target/x86_64-unknown-linux-gnu/release/rustfs /usr/local/bin/rustfs \
;; \
"linux/arm64") \
echo "🔨 Building for arm64..." && \
rustup target add aarch64-unknown-linux-gnu && \
cargo build --release --target aarch64-unknown-linux-gnu --bin rustfs -j $(nproc) && \
cp target/aarch64-unknown-linux-gnu/release/rustfs /usr/local/bin/rustfs \
;; \
*) \
echo "❌ Unsupported platform: $TARGETPLATFORM" && exit 1 \
;; \
# Build RustFS (target depends on TARGETPLATFORM)
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/usr/src/rustfs/target \
set -eux; \
case "${TARGETPLATFORM:-linux/amd64}" in \
linux/amd64) \
echo "Building for x86_64-unknown-linux-gnu"; \
cargo build --release --locked --target x86_64-unknown-linux-gnu --bin rustfs -j "$(nproc)"; \
install -m 0755 target/x86_64-unknown-linux-gnu/release/rustfs /usr/local/bin/rustfs \
;; \
linux/arm64) \
echo "Building for aarch64-unknown-linux-gnu"; \
cargo build --release --locked --target aarch64-unknown-linux-gnu --bin rustfs -j "$(nproc)"; \
install -m 0755 target/aarch64-unknown-linux-gnu/release/rustfs /usr/local/bin/rustfs \
;; \
*) \
echo "Unsupported TARGETPLATFORM=${TARGETPLATFORM}" >&2; exit 1 \
;; \
esac
# Runtime stage - Ubuntu minimal for better compatibility
# -----------------------------
# Runtime stage (Ubuntu minimal)
# -----------------------------
FROM ubuntu:22.04
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
ARG BUILD_DATE
ARG VCS_REF
LABEL name="RustFS (dev-local)" \
maintainer="RustFS Team" \
build-date="${BUILD_DATE}" \
vcs-ref="${VCS_REF}" \
description="RustFS - local development image built from source (NOT for production)."
# Minimal runtime deps: certificates + tzdata + coreutils (for chroot --userspec)
RUN set -eux; \
export DEBIAN_FRONTEND=noninteractive; \
apt-get update; \
apt-get install -y --no-install-recommends \
ca-certificates \
tzdata \
wget \
coreutils \
passwd \
&& rm -rf /var/lib/apt/lists/*
coreutils; \
rm -rf /var/lib/apt/lists/*
# Create rustfs user and group
RUN groupadd -g 1000 rustfs && \
useradd -d /app -g rustfs -u 1000 -s /bin/bash rustfs
# Create a conventional runtime user/group (final switch happens in entrypoint via chroot --userspec)
RUN set -eux; \
groupadd -g 1000 rustfs; \
useradd -u 1000 -g rustfs -M -s /usr/sbin/nologin rustfs
WORKDIR /app
# Create data directories
RUN mkdir -p /data/rustfs{0,1,2,3} && \
chown -R rustfs:rustfs /data /app
# Prepare data/log directories with sane defaults
RUN set -eux; \
mkdir -p /data /logs; \
chown -R rustfs:rustfs /data /logs /app; \
chmod 0750 /data /logs
# Copy binary from builder stage
COPY --from=builder /usr/local/bin/rustfs /app/rustfs
RUN chmod +x /app/rustfs && chown rustfs:rustfs /app/rustfs
# Copy entrypoint script
# Copy the freshly built binary and the entrypoint
COPY --from=builder /usr/local/bin/rustfs /usr/bin/rustfs
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN chmod +x /usr/bin/rustfs /entrypoint.sh
# Switch to non-root user
USER rustfs
# Default environment (override in docker run/compose as needed)
ENV RUSTFS_ADDRESS=":9000" \
RUSTFS_ACCESS_KEY="rustfsadmin" \
RUSTFS_SECRET_KEY="rustfsadmin" \
RUSTFS_CONSOLE_ENABLE="true" \
RUSTFS_VOLUMES="/data" \
RUST_LOG="warn" \
RUSTFS_OBS_LOG_DIRECTORY="/logs" \
RUSTFS_SINKS_FILE_PATH="/logs" \
RUSTFS_USERNAME="rustfs" \
RUSTFS_GROUPNAME="rustfs" \
RUSTFS_UID="1000" \
RUSTFS_GID="1000"
# Expose ports
EXPOSE 9000
VOLUME ["/data", "/logs"]
# Environment variables
ENV RUSTFS_ACCESS_KEY=rustfsadmin \
RUSTFS_SECRET_KEY=rustfsadmin \
RUSTFS_ADDRESS=":9000" \
RUSTFS_CONSOLE_ENABLE=true \
RUSTFS_VOLUMES=/data \
RUST_LOG=warn
# Volume for data
VOLUME ["/data"]
# Set entrypoint and default command
# Keep root here; entrypoint will drop privileges using chroot --userspec
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/app/rustfs"]
CMD ["/usr/bin/rustfs"]

View File

@@ -23,7 +23,7 @@ fmt-check:
.PHONY: clippy
clippy:
@echo "🔍 Running clippy checks..."
cargo clippy --fix --allow-dirty
cargo clippy --fix --allow-dirty
cargo clippy --all-targets --all-features -- -D warnings
.PHONY: check
@@ -210,7 +210,9 @@ docker-build-production:
docker-build-source:
@echo "🏗️ Building single-architecture source Docker image..."
@echo "💡 Consider using 'make docker-dev-local' for multi-arch support"
$(DOCKER_CLI) build -f $(DOCKERFILE_SOURCE) -t rustfs:source .
DOCKER_BUILDKIT=1 $(DOCKER_CLI) build \
--build-arg BUILDKIT_INLINE_CACHE=1 \
-f $(DOCKERFILE_SOURCE) -t rustfs:source .
# ========================================================================================
# Development Environment

View File

@@ -100,6 +100,7 @@ rustfs-rio.workspace = true
rustfs-signer.workspace = true
rustfs-checksums.workspace = true
futures-util.workspace = true
async-recursion.workspace = true
[target.'cfg(not(windows))'.dependencies]
nix = { workspace = true }

View File

@@ -440,6 +440,7 @@ impl LocalDisk {
}
#[tracing::instrument(level = "debug", skip(self))]
#[async_recursion::async_recursion]
pub async fn delete_file(
&self,
base_path: &PathBuf,
@@ -803,13 +804,17 @@ impl LocalDisk {
Ok(())
}
async fn scan_dir<W: AsyncWrite + Unpin>(
#[async_recursion::async_recursion]
async fn scan_dir<W>(
&self,
current: &mut String,
opts: &WalkDirOptions,
out: &mut MetacacheWriter<W>,
objs_returned: &mut i32,
) -> Result<()> {
) -> Result<()>
where
W: AsyncWrite + Unpin + Send,
{
let forward = {
opts.forward_to.as_ref().filter(|v| v.starts_with(&*current)).map(|v| {
let forward = v.trim_start_matches(&*current);

View File

@@ -177,15 +177,17 @@ impl S3PeerSys {
let pools = cli.get_pools();
let idx = i;
if pools.unwrap_or_default().contains(&idx) {
per_pool_errs.push(errors[j].as_ref());
per_pool_errs.push(errors[j].clone());
}
// TODO: reduceWriteQuorumErrs
if let Some(pool_err) =
reduce_write_quorum_errs(&per_pool_errs, BUCKET_OP_IGNORED_ERRS, (per_pool_errs.len() / 2) + 1)
{
return Err(pool_err);
}
}
}
// TODO:
Ok(())
}
pub async fn list_bucket(&self, opts: &BucketOptions) -> Result<Vec<BucketInfo>> {
@@ -387,7 +389,6 @@ impl PeerS3Client for LocalPeerS3Client {
if opts.force_create && matches!(e, Error::VolumeExists) {
return Ok(());
}
Err(e)
}
}
@@ -405,7 +406,9 @@ impl PeerS3Client for LocalPeerS3Client {
}
}
// TODO: reduceWriteQuorumErrs
if let Some(err) = reduce_write_quorum_errs(&errs, BUCKET_OP_IGNORED_ERRS, (local_disks.len() / 2) + 1) {
return Err(err);
}
Ok(())
}

View File

@@ -1221,7 +1221,7 @@ impl StorageAPI for ECStore {
}
if let Err(err) = self.peer_sys.make_bucket(bucket, opts).await {
let err = err.into();
let err = to_object_err(err.into(), vec![bucket]);
if !is_err_bucket_exists(&err) {
let _ = self
.delete_bucket(
@@ -1234,7 +1234,6 @@ impl StorageAPI for ECStore {
)
.await;
}
return Err(err);
};

View File

@@ -231,6 +231,20 @@ Retrieve an object from S3 with two operation modes: read content directly or do
- `local_path` (string, optional): Local file path (required when mode is "download")
- `max_content_size` (number, optional): Maximum content size in bytes for read mode (default: 1MB)
### `create_bucket`
Create a new S3 bucket with the specified name.
**Parameters:**
- `bucket_name` (string): Source S3 bucket.
### `delete_bucket`
Delete the specified S3 bucket. If the bucket is not empty, the deletion will fail. You should delete all objects and objects inside them before calling this method.**WARNING: This operation will permanently delete the bucket and all objects within it!**
- `bucket_name` (string): Source S3 bucket.
## Architecture
The MCP server is built with a modular architecture:

View File

@@ -151,6 +151,36 @@ impl S3Client {
Ok(Self { client })
}
pub async fn create_bucket(&self, bucket_name: &str) -> Result<BucketInfo> {
info!("Creating S3 bucket: {}", bucket_name);
self.client
.create_bucket()
.bucket(bucket_name)
.send()
.await
.context(format!("Failed to create S3 bucket: {bucket_name}"))?;
info!("Bucket '{}' created successfully", bucket_name);
Ok(BucketInfo {
name: bucket_name.to_string(),
creation_date: None, // Creation date not returned by create_bucket
})
}
pub async fn delete_bucket(&self, bucket_name: &str) -> Result<()> {
info!("Deleting S3 bucket: {}", bucket_name);
self.client
.delete_bucket()
.bucket(bucket_name)
.send()
.await
.context(format!("Failed to delete S3 bucket: {bucket_name}"))?;
info!("Bucket '{}' deleted successfully", bucket_name);
Ok(())
}
pub async fn list_buckets(&self) -> Result<Vec<BucketInfo>> {
debug!("Listing S3 buckets");

View File

@@ -54,6 +54,18 @@ pub struct UploadFileRequest {
pub cache_control: Option<String>,
}
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct CreateBucketReqeust {
#[schemars(description = "Name of the S3 bucket to create")]
pub bucket_name: String,
}
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct DeleteBucketReqeust {
#[schemars(description = "Name of the S3 bucket to delete")]
pub bucket_name: String,
}
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct GetObjectRequest {
#[schemars(description = "Name of the S3 bucket")]
@@ -110,6 +122,53 @@ impl RustfsMcpServer {
})
}
#[tool(description = "Create a new S3 bucket with the specified name")]
pub async fn create_bucket(&self, Parameters(req): Parameters<CreateBucketReqeust>) -> String {
info!("Executing create_bucket tool for bucket: {}", req.bucket_name);
match self.s3_client.create_bucket(&req.bucket_name).await {
Ok(_) => {
format!("Successfully created bucket: {}", req.bucket_name)
}
Err(e) => {
format!("Failed to create bucket '{}': {:?}", req.bucket_name, e)
}
}
}
#[tool(description = "Delete an existing S3 bucket with the specified name")]
pub async fn delete_bucket(&self, Parameters(req): Parameters<DeleteBucketReqeust>) -> String {
info!("Executing delete_bucket tool for bucket: {}", req.bucket_name);
// check if bucket is empty, if not, can not delete bucket directly.
let object_result = match self
.s3_client
.list_objects_v2(&req.bucket_name, ListObjectsOptions::default())
.await
{
Ok(result) => result,
Err(e) => {
error!("Failed to list objects in bucket '{}': {:?}", req.bucket_name, e);
return format!("Failed to list objects in bucket '{}': {:?}", req.bucket_name, e);
}
};
if !object_result.objects.is_empty() {
error!("Bucket '{}' is not empty", req.bucket_name);
return format!("Failed to delete bucket '{}': bucket is not empty", req.bucket_name);
}
// delete the bucket.
match self.s3_client.delete_bucket(&req.bucket_name).await {
Ok(_) => {
format!("Successfully deleted bucket: {}", req.bucket_name)
}
Err(e) => {
format!("Failed to delete bucket '{}': {:?}", req.bucket_name, e)
}
}
}
#[tool(description = "List all S3 buckets accessible with the configured credentials")]
pub async fn list_buckets(&self) -> String {
info!("Executing list_buckets tool");
@@ -667,4 +726,20 @@ mod tests {
assert_eq!(read_mode_deser, GetObjectMode::Read);
assert_eq!(download_mode_deser, GetObjectMode::Download);
}
#[test]
fn test_bucket_creation() {
let request = CreateBucketReqeust {
bucket_name: "test-bucket".to_string(),
};
assert_eq!(request.bucket_name, "test-bucket");
}
#[test]
fn test_bucket_deletion() {
let request = DeleteBucketReqeust {
bucket_name: "test-bucket".to_string(),
};
assert_eq!(request.bucket_name, "test-bucket");
}
}

165
entrypoint.sh Normal file → Executable file
View File

@@ -1,104 +1,81 @@
#!/bin/bash
#!/bin/sh
set -e
APP_USER=rustfs
APP_GROUP=rustfs
APP_UID=${PUID:-1000}
APP_GID=${PGID:-1000}
# Parse RUSTFS_VOLUMES into array (support space, comma, tab as separator)
VOLUME_RAW="${RUSTFS_VOLUMES:-/data}"
# Replace comma and tab with space, then split
VOLUME_RAW=$(echo "$VOLUME_RAW" | tr ',\t' ' ')
read -ra ALL_VOLUMES <<< "$VOLUME_RAW"
# Only keep local volumes (start with /, not http/https)
LOCAL_VOLUMES=()
for vol in "${ALL_VOLUMES[@]}"; do
if [[ "$vol" =~ ^/ ]] && [[ ! "$vol" =~ ^https?:// ]]; then
# Not a URL (http/https), just a local path
LOCAL_VOLUMES+=("$vol")
fi
# If it's a URL (http/https), skip
# If it's an empty string, skip
# If it's a local path, keep
# (We don't support other protocols here)
done
# Always ensure /logs is included for permission fix
include_logs=1
for vol in "${LOCAL_VOLUMES[@]}"; do
if [ "$vol" = "/logs" ]; then
include_logs=0
break
fi
done
if [ $include_logs -eq 1 ]; then
LOCAL_VOLUMES+=("/logs")
# 1) Normalize command:
# - No arguments: default to execute rustfs
# - First argument starts with '-': treat as rustfs arguments, auto-prefix rustfs
# - First argument is 'rustfs': replace with absolute path to avoid PATH interference
if [ $# -eq 0 ] || [ "${1#-}" != "$1" ]; then
set -- /usr/bin/rustfs "$@"
elif [ "$1" = "rustfs" ]; then
shift
set -- /usr/bin/rustfs "$@"
fi
# Try to update rustfs UID/GID if needed (requires root and shadow tools)
update_user_group_ids() {
local uid="$1"
local gid="$2"
local user="$3"
local group="$4"
local updated=0
if [ "$(id -u "$user")" != "$uid" ]; then
if command -v usermod >/dev/null 2>&1; then
echo "🔧 Updating UID of $user to $uid"
usermod -u "$uid" "$user"
updated=1
# 2) Parse and create local mount directories (ignore http/https), ensure /logs is included
VOLUME_RAW="${RUSTFS_VOLUMES:-/data}"
# Convert comma/tab to space
VOLUME_LIST=$(echo "$VOLUME_RAW" | tr ',\t' ' ')
LOCAL_VOLUMES=""
for vol in $VOLUME_LIST; do
case "$vol" in
/*)
case "$vol" in
http://*|https://*) : ;;
*) LOCAL_VOLUMES="$LOCAL_VOLUMES $vol" ;;
esac
;;
*)
: # skip non-local paths
;;
esac
done
# Ensure /logs is included
case " $LOCAL_VOLUMES " in
*" /logs "*) : ;;
*) LOCAL_VOLUMES="$LOCAL_VOLUMES /logs" ;;
esac
echo "Initializing mount directories:$LOCAL_VOLUMES"
for vol in $LOCAL_VOLUMES; do
if [ ! -d "$vol" ]; then
echo " mkdir -p $vol"
mkdir -p "$vol"
# If target user is specified, try to set directory owner to that user (non-recursive to avoid large disk overhead)
if [ -n "$RUSTFS_UID" ] && [ -n "$RUSTFS_GID" ]; then
chown "$RUSTFS_UID:$RUSTFS_GID" "$vol" 2>/dev/null || true
elif [ -n "$RUSTFS_USERNAME" ] && [ -n "$RUSTFS_GROUPNAME" ]; then
chown "$RUSTFS_USERNAME:$RUSTFS_GROUPNAME" "$vol" 2>/dev/null || true
fi
fi
if [ "$(id -g "$group")" != "$gid" ]; then
if command -v groupmod >/dev/null 2>&1; then
echo "🔧 Updating GID of $group to $gid"
groupmod -g "$gid" "$group"
updated=1
done
# 3) Default credentials warning
if [ "${RUSTFS_ACCESS_KEY}" = "rustfsadmin" ] || [ "${RUSTFS_SECRET_KEY}" = "rustfsadmin" ]; then
echo "!!!WARNING: Using default RUSTFS_ACCESS_KEY or RUSTFS_SECRET_KEY. Override them in production!"
fi
# 4) Start with specified user
docker_switch_user() {
if [ -n "${RUSTFS_USERNAME}" ] && [ -n "${RUSTFS_GROUPNAME}" ]; then
if [ -n "${RUSTFS_UID}" ] && [ -n "${RUSTFS_GID}" ]; then
# Execute with numeric UID:GID directly (doesn't depend on user existing in system)
exec chroot --userspec="${RUSTFS_UID}:${RUSTFS_GID}" / "$@"
else
# When only names are provided, create minimal passwd/group entries with 1000:1000; deduplicate before writing
if ! grep -q "^${RUSTFS_USERNAME}:" /etc/passwd 2>/dev/null; then
echo "${RUSTFS_USERNAME}:x:1000:1000:${RUSTFS_USERNAME}:/nonexistent:/sbin/nologin" >> /etc/passwd
fi
if ! grep -q "^${RUSTFS_GROUPNAME}:" /etc/group 2>/dev/null; then
echo "${RUSTFS_GROUPNAME}:x:1000:" >> /etc/group
fi
exec chroot --userspec="${RUSTFS_USERNAME}:${RUSTFS_GROUPNAME}" / "$@"
fi
else
# If no user is specified, keep as root (container has minimal privilege practices that can be configured separately)
exec "$@"
fi
return $updated
}
echo "📦 Initializing mount directories: ${LOCAL_VOLUMES[*]}"
for vol in "${LOCAL_VOLUMES[@]}"; do
if [ ! -d "$vol" ]; then
echo "📁 Creating directory: $vol"
mkdir -p "$vol"
fi
# Alpine busybox stat does not support -c, coreutils is required
dir_uid=$(stat -c '%u' "$vol")
dir_gid=$(stat -c '%g' "$vol")
if [ "$dir_uid" != "$APP_UID" ] || [ "$dir_gid" != "$APP_GID" ]; then
if [[ "$SKIP_CHOWN" != "true" ]]; then
# Prefer to update rustfs user/group UID/GID
update_user_group_ids "$dir_uid" "$dir_gid" "$APP_USER" "$APP_GROUP" || \
{
echo "🔧 Fixing ownership for: $vol$APP_USER:$APP_GROUP"
if [[ -n "$CHOWN_RECURSION_DEPTH" ]]; then
echo "🔧 Applying ownership fix with recursion depth: $CHOWN_RECURSION_DEPTH"
find "$vol" -mindepth 0 -maxdepth "$CHOWN_RECURSION_DEPTH" -exec chown "$APP_USER:$APP_GROUP" {} \;
else
echo "🔧 Applying ownership fix recursively (full depth)"
chown -R "$APP_USER:$APP_GROUP" "$vol"
fi
}
else
echo "⚠️ SKIP_CHOWN is enabled. Skipping ownership fix for: $vol"
fi
fi
chmod 700 "$vol"
done
# Warn if default credentials are used
if [[ "$RUSTFS_ACCESS_KEY" == "rustfsadmin" || "$RUSTFS_SECRET_KEY" == "rustfsadmin" ]]; then
echo "⚠️ WARNING: Using default RUSTFS_ACCESS_KEY or RUSTFS_SECRET_KEY"
echo "⚠️ It is strongly recommended to override these values in production!"
fi
echo "🚀 Starting application: $*"
exec gosu "$APP_USER" "$@"
echo "Starting: $*"
docker_switch_user "$@"

View File

@@ -311,7 +311,7 @@ impl S3 for FS {
.make_bucket(
&bucket,
&MakeBucketOptions {
force_create: true,
force_create: false, // TODO: force support
lock_enabled: object_lock_enabled_for_bucket.is_some_and(|v| v),
..Default::default()
},
@@ -984,6 +984,7 @@ impl S3 for FS {
accept_ranges: Some("bytes".to_string()),
content_range,
e_tag: info.etag,
metadata: Some(info.user_defined),
..Default::default()
};