Skip to content

Commit a657cba

Browse files
feat: simplify memory limiting to Linux-only with malloc_trim
- Remove macOS malloc_zone_pressure_relief code (doesn't actually work) - Skip memory-bound tests on macOS (malloc doesn't return memory to OS) - Simplify CI workflow to single job running make test-all - Document Linux requirement for memory limiting in README
1 parent b891911 commit a657cba

34 files changed

Lines changed: 1248 additions & 1003 deletions

.github/workflows/test.yml

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,6 @@ on:
1111
workflow_dispatch:
1212

1313
jobs:
14-
unit-tests:
15-
runs-on: ubuntu-latest
16-
timeout-minutes: 10
17-
steps:
18-
- uses: actions/checkout@v6
19-
20-
- name: Set up Python
21-
uses: actions/setup-python@v6
22-
with:
23-
python-version: '3.14'
24-
cache: 'pip'
25-
26-
- name: Install dependencies
27-
run: pip install -e ".[dev]"
28-
29-
- name: Run unit tests
30-
run: make test
31-
3214
integration-tests:
3315
runs-on: ubuntu-latest
3416
timeout-minutes: 15

Makefile

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ test: test-unit
55

66
# Run unit tests (excludes e2e and ha tests)
77
test-unit:
8-
pytest -m "not e2e and not ha" -v
8+
uv run pytest -m "not e2e and not ha" -v -n auto
99

10-
# Run all tests with containers
10+
# Run all tests with containers (parallel execution)
1111
test-all:
1212
@docker compose -f tests/docker-compose.yml down 2>/dev/null || true
1313
@docker compose -f tests/docker-compose.yml up -d
1414
@sleep 3
15-
@AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin pytest -v; \
15+
@AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin uv run pytest -v -n auto --dist loadgroup; \
1616
EXIT_CODE=$$?; \
1717
docker compose -f tests/docker-compose.yml down; \
1818
exit $$EXIT_CODE
@@ -23,7 +23,7 @@ test-run:
2323
@docker compose -f tests/docker-compose.yml down 2>/dev/null || true
2424
@docker compose -f tests/docker-compose.yml up -d
2525
@sleep 3
26-
@AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin pytest -v $(TESTS); \
26+
@AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin uv run pytest -v -n auto --dist loadgroup $(TESTS); \
2727
EXIT_CODE=$$?; \
2828
docker compose -f tests/docker-compose.yml down; \
2929
exit $$EXIT_CODE

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,9 @@ helm install s3proxy oci://ghcr.io/serversidehannes/s3proxy-python/charts/s3prox
175175
| `redis-ha.enabled` | `true` | Deploy embedded Redis HA |
176176
| `gateway.enabled` | `false` | Create `s3-gateway` service |
177177
| `ingress.enabled` | `false` | Enable ingress |
178-
| `performance.memoryLimitMb` | `64` | Memory budget for concurrency |
178+
| `performance.memoryLimitMb` | `64` | Memory budget for concurrency (Linux only) |
179+
180+
> **Note:** Memory-based concurrency limiting requires Linux. The `malloc_trim` syscall used to release memory back to the OS is not available on macOS.
179181
180182
[chart/values.yaml](chart/values.yaml) for all options.
181183

e2e/scripts/verify-encryption-k8s.sh

Lines changed: 11 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ verify_encryption() {
3838
for F in \$FILES; do
3939
[ -z \"\$F\" ] && continue
4040
41+
FAIL_REASON=''
42+
43+
# Entropy check - encrypted data should have high entropy
4144
# Stream only first 4KB instead of downloading entire file
4245
if ! timeout 30 mc cat \"minio/$BUCKET/${PATH_PREFIX}\$F\" 2>/dev/null | head -c 4096 > /tmp/f; then
4346
SKIPPED=\$((SKIPPED + 1))
@@ -55,48 +58,22 @@ verify_encryption() {
5558
5659
CHECKED=\$((CHECKED + 1))
5760
58-
# Check for known unencrypted file magic bytes
59-
MAGIC=\$(od -A n -t x1 -N 8 /tmp/f | tr -d ' ')
60-
FAIL_REASON=''
61-
62-
# gzip: 1f8b
63-
echo \"\$MAGIC\" | grep -q '^1f8b' && FAIL_REASON='gzip magic'
64-
# tar: 7573746172 at offset 257 (check first bytes for common tar patterns)
65-
[ -z \"\$FAIL_REASON\" ] && od -A n -t x1 -j 257 -N 5 /tmp/f 2>/dev/null | tr -d ' ' | grep -q '^7573746172' && FAIL_REASON='tar magic'
66-
# zstd: 28b52ffd
67-
[ -z \"\$FAIL_REASON\" ] && echo \"\$MAGIC\" | grep -q '^28b52ffd' && FAIL_REASON='zstd magic'
68-
# bzip2: 425a68 (BZh)
69-
[ -z \"\$FAIL_REASON\" ] && echo \"\$MAGIC\" | grep -q '^425a68' && FAIL_REASON='bzip2 magic'
70-
# xz: fd377a585a00
71-
[ -z \"\$FAIL_REASON\" ] && echo \"\$MAGIC\" | grep -q '^fd377a585a00' && FAIL_REASON='xz magic'
72-
# zip/jar: 504b0304
73-
[ -z \"\$FAIL_REASON\" ] && echo \"\$MAGIC\" | grep -q '^504b0304' && FAIL_REASON='zip magic'
74-
# JSON: starts with { or [ AND contains JSON structure (quotes, colons, commas)
75-
[ -z \"\$FAIL_REASON\" ] && echo \"\$MAGIC\" | grep -q '^7b\\|^5b' && head -c 100 /tmp/f | grep -q '[\":,]' && FAIL_REASON='JSON plaintext'
76-
# XML: must start with <?xml declaration OR have multiple tag-like structures
77-
# Single < followed by letter is not enough - encrypted data can randomly start with <
78-
[ -z \"\$FAIL_REASON\" ] && echo \"\$MAGIC\" | grep -q '^3c3f786d6c' && FAIL_REASON='XML plaintext (<?xml declaration)'
79-
# Check for multiple opening tags like <tag> patterns (at least 2)
80-
[ -z \"\$FAIL_REASON\" ] && [ \$(head -c 200 /tmp/f | grep -oE '<[a-zA-Z][a-zA-Z0-9]*[> ]' | wc -l) -ge 2 ] && FAIL_REASON='XML plaintext (multiple tags)'
81-
82-
if [ -n \"\$FAIL_REASON\" ]; then
83-
FAILED=\$((FAILED + 1))
84-
FAILED_FILES=\"\${FAILED_FILES} ✗ \$F (\$FAIL_REASON)\n\"
85-
rm -f /tmp/f
86-
continue
87-
fi
88-
89-
# Entropy check as secondary verification
61+
# Entropy check - encrypted data should have high entropy (>6.0 bits/byte)
9062
ENT=\$(cat /tmp/f | od -A n -t u1 | tr ' ' '\n' | grep -v '^\$' | sort | uniq -c | awk '
9163
BEGIN{t=0;e=0}{c[\$2]=\$1;t+=\$1}END{for(b in c){p=c[b]/t;if(p>0)e-=p*log(p)/log(2)}printf\"%.2f\",e}')
64+
rm -f /tmp/f
9265
9366
if awk \"BEGIN{exit!(\$ENT<6.0)}\"; then
67+
[ -n \"\$FAIL_REASON\" ] && FAIL_REASON=\"\$FAIL_REASON + \"
68+
FAIL_REASON=\"\${FAIL_REASON}low entropy: \$ENT\"
69+
fi
70+
71+
if [ -n \"\$FAIL_REASON\" ]; then
9472
FAILED=\$((FAILED + 1))
95-
FAILED_FILES=\"\${FAILED_FILES} ✗ \$F (low entropy: \$ENT)\n\"
73+
FAILED_FILES=\"\${FAILED_FILES} ✗ \$F (\$FAIL_REASON)\n\"
9674
else
9775
PASSED=\$((PASSED + 1))
9876
fi
99-
rm -f /tmp/f
10077
10178
[ \$((CHECKED % 10)) -eq 0 ] && echo \" Progress: \$CHECKED/\$COUNT files checked (Encrypted: \$PASSED, Unencrypted: \$FAILED, Skipped: \$SKIPPED)\"
10279
done

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dev = [
2929
"pytest>=8.0.0",
3030
"pytest-asyncio>=0.23.0",
3131
"pytest-cov>=4.1.0",
32+
"pytest-xdist>=3.5.0",
3233
"moto[s3]>=5.0.0",
3334
"ruff>=0.2.0",
3435
"mypy>=1.8.0",

s3proxy/app.py

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -49,25 +49,40 @@ def load_credentials() -> dict[str, str]:
4949

5050

5151
def create_lifespan(
52-
settings: Settings, multipart_manager: MultipartStateManager
52+
settings: Settings, credentials_store: dict[str, str]
5353
) -> AsyncIterator[None]:
5454
"""Create lifespan context manager for FastAPI app.
5555
5656
Args:
5757
settings: Application settings.
58-
multipart_manager: The multipart state manager to configure.
58+
credentials_store: Credentials for signature verification.
5959
6060
Returns:
6161
A lifespan context manager for FastAPI.
6262
"""
6363

6464
@asynccontextmanager
65-
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
65+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
6666
logger.info("Starting", endpoint=settings.s3_endpoint, port=settings.port)
67+
68+
# Initialize Redis FIRST, then create manager with correct store
6769
await init_redis(settings.redis_url or None, settings.redis_password or None)
68-
# Set the appropriate store after Redis is initialized
69-
multipart_manager.set_store(create_state_store())
70+
store = create_state_store()
71+
multipart_manager = MultipartStateManager(
72+
store=store,
73+
ttl_seconds=settings.redis_upload_ttl_seconds,
74+
)
75+
76+
# Create handler and verifier with properly initialized manager
77+
verifier = SigV4Verifier(credentials_store)
78+
handler = S3ProxyHandler(settings, credentials_store, multipart_manager)
79+
80+
# Store in app.state for route access
81+
app.state.handler = handler
82+
app.state.verifier = verifier
83+
7084
yield
85+
7186
await close_redis()
7287
await close_http_client()
7388
logger.info("Shutting down")
@@ -85,19 +100,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
85100
Configured FastAPI application instance.
86101
"""
87102
settings = settings or Settings()
88-
89103
credentials_store = load_credentials()
90-
multipart_manager = MultipartStateManager(
91-
ttl_seconds=settings.redis_upload_ttl_seconds,
92-
)
93-
verifier = SigV4Verifier(credentials_store)
94-
handler = S3ProxyHandler(settings, credentials_store, multipart_manager)
95104

96-
lifespan = create_lifespan(settings, multipart_manager)
105+
lifespan = create_lifespan(settings, credentials_store)
97106
app = FastAPI(title="S3Proxy", lifespan=lifespan, docs_url=None, redoc_url=None)
98107

99108
_register_exception_handlers(app)
100-
_register_routes(app, handler, verifier)
109+
_register_routes(app)
101110

102111
return app
103112

@@ -134,9 +143,7 @@ async def s3_exception_handler(request: Request, exc: HTTPException):
134143
)
135144

136145

137-
def _register_routes(
138-
app: FastAPI, handler: S3ProxyHandler, verifier: SigV4Verifier
139-
) -> None:
146+
def _register_routes(app: FastAPI) -> None:
140147
"""Register health check and proxy routes."""
141148

142149
@app.get("/healthz")
@@ -149,7 +156,9 @@ async def health():
149156
methods=["GET", "PUT", "POST", "DELETE", "HEAD"],
150157
)
151158
async def proxy(request: Request, path: str): # noqa: ARG001
152-
return await handle_proxy_request(request, handler, verifier)
159+
return await handle_proxy_request(
160+
request, request.app.state.handler, request.app.state.verifier
161+
)
153162

154163

155164
# Default app instance for ASGI servers (uvicorn, gunicorn)

0 commit comments

Comments
 (0)