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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,27 @@ All settings use the `RUSTFS_` prefix for clarity.

---

### HTTP/HTTPS and TLS configuration

The connection protocol has one source of truth: `RUSTFS_USE_SSL`. It controls the boto3 client and the protocol used for direct URLs. `RUSTFS_ENDPOINT` may include a scheme, but when it does, that scheme must match `RUSTFS_USE_SSL`.

Supported configurations:

| `RUSTFS_ENDPOINT` | `RUSTFS_USE_SSL` | Result |
|---|---:|---|
| `http://localhost:9000` | `False` | HTTP |
| `https://localhost:9443` | `True` | HTTPS |
| `localhost:9000` | `False` | HTTP (scheme added) |
| `localhost:9443` | `True` | HTTPS (scheme added) |

Custom ports are preserved. Contradictory combinations such as an HTTPS endpoint with `RUSTFS_USE_SSL=False`, or an HTTP endpoint with `RUSTFS_USE_SSL=True`, are rejected with `ImproperlyConfigured`.

`RUSTFS_VERIFY_SSL` controls TLS certificate verification passed to boto3. It defaults to `True`. Set it to `False` only when certificate verification is intentionally disabled, such as when using a development certificate that is not trusted by the system.

`RUSTFS_SECURE_URLS` does not override the connection protocol. Direct and custom-domain URLs use the protocol resolved from `RUSTFS_ENDPOINT` and `RUSTFS_USE_SSL`, while presigned URLs are generated by boto3 for the same configured endpoint.

---

## Management Commands

### `rustfs_health`
Expand Down
30 changes: 30 additions & 0 deletions django_rustfs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

from typing import Any
from urllib.parse import urlsplit

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
Expand Down Expand Up @@ -46,6 +47,35 @@
}


def resolve_endpoint(endpoint: str, use_ssl: bool) -> str:
"""Resolve and validate the RustFS endpoint/TLS configuration.

RUSTFS_ENDPOINT may include an http:// or https:// scheme. When a scheme
is present it must agree with RUSTFS_USE_SSL. When the endpoint has no
scheme, the scheme is derived from RUSTFS_USE_SSL.
"""
endpoint = endpoint.strip().rstrip("/")
if not endpoint:
return endpoint

if "://" not in endpoint:
return f"{'https' if use_ssl else 'http'}://{endpoint}"

parsed = urlsplit(endpoint)
if parsed.scheme not in {"http", "https"}:
raise ImproperlyConfigured("django-rustfs: RUSTFS_ENDPOINT must use http:// or https://.")

endpoint_uses_ssl = parsed.scheme == "https"
if endpoint_uses_ssl != bool(use_ssl):
expected = "https" if use_ssl else "http"
raise ImproperlyConfigured(
"django-rustfs: RUSTFS_ENDPOINT and RUSTFS_USE_SSL disagree. "
f"Endpoint uses {parsed.scheme} but RUSTFS_USE_SSL={use_ssl!r}; "
f"use an {expected} endpoint or set RUSTFS_USE_SSL accordingly."
)
return endpoint


class Settings:
"""
Lazy settings accessor with validation.
Expand Down
17 changes: 13 additions & 4 deletions django_rustfs/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from django.utils.deconstruct import deconstructible
from django.utils.encoding import filepath_to_uri

from django_rustfs.conf import DEFAULTS, Settings, resolve_endpoint


class RustFSError(Exception):
"""Base exception for RustFS storage errors."""
Expand Down Expand Up @@ -103,11 +105,16 @@ def __init__(self, **kwargs: Any) -> None:
self.location = self._setting("LOCATION", kwargs, "", kwarg_key="location").lstrip("/")
self.region = self._setting("REGION", kwargs, "us-east-1", kwarg_key="region")
self.use_ssl = self._setting("USE_SSL", kwargs, False, kwarg_key="use_ssl")
self.endpoint_url = resolve_endpoint(self.endpoint_url, self.use_ssl)
self.verify_ssl = self._setting("VERIFY_SSL", kwargs, True, kwarg_key="verify_ssl")
self.max_pool_connections = self._setting(
"MAX_POOL_CONNECTIONS", kwargs, 10, kwarg_key="max_pool_connections"
)
self.presign_urls = self._setting("PRESIGN_URLS", kwargs, True, kwarg_key="presign_urls")
self.connect_timeout = self._setting(
"CONNECT_TIMEOUT", kwargs, 5, kwarg_key="connect_timeout"
)
self.read_timeout = self._setting("READ_TIMEOUT", kwargs, 30, kwarg_key="read_timeout")

# Validate required settings
self._validate_config()
Expand Down Expand Up @@ -141,6 +148,8 @@ def _setting(
key = kwarg_key or name.lower()
if key in kwargs:
return kwargs.pop(key)
if name in DEFAULTS:
return Settings.get(name)
return getattr(django_settings, f"RUSTFS_{name}", default)

def _validate_config(self) -> None:
Expand All @@ -165,8 +174,8 @@ def client(self) -> Any:
if self._client is None:
config = botocore.config.Config(
max_pool_connections=self.max_pool_connections,
connect_timeout=5,
read_timeout=30,
connect_timeout=self.connect_timeout,
read_timeout=self.read_timeout,
)
self._client = boto3.client(
"s3",
Expand Down Expand Up @@ -359,7 +368,7 @@ def url(self, name: Optional[str]) -> str:

# If custom domain is set, use it for direct URLs
if self.custom_domain:
scheme = "https" if self.secure_urls else "http"
scheme = "https" if self.use_ssl else "http"
domain = self.custom_domain.rstrip("/")
return f"{scheme}://{domain}/{filepath_to_uri(key)}"

Expand All @@ -377,7 +386,7 @@ def url(self, name: Optional[str]) -> str:
raise RustFSError(f"Failed to generate URL for '{name}': {e}") from e

# Direct URL via endpoint
scheme = "https" if self.secure_urls else "http"
scheme = "https" if self.use_ssl else "http"
endpoint = self.endpoint_url.rstrip("/")
return (
f"{scheme}://{endpoint.split('://', 1)[-1]}/{self.bucket_name}/{filepath_to_uri(key)}"
Expand Down
28 changes: 27 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,38 @@ All settings use the `RUSTFS_` prefix.
| Setting | Default | Description |
|---|---|---|
| `RUSTFS_REGION` | `"us-east-1"` | AWS region name |
| `RUSTFS_USE_SSL` | `False` | Use SSL/TLS for connections |
| `RUSTFS_USE_SSL` | `False` | Select HTTP or HTTPS when the endpoint has no scheme; must agree with an explicit endpoint scheme |
| `RUSTFS_VERIFY_SSL` | `True` | Verify SSL certificates |
| `RUSTFS_MAX_POOL_CONNECTIONS` | `10` | Max boto3 connection pool size |
| `RUSTFS_CONNECT_TIMEOUT` | `5` | Connection timeout in seconds |
| `RUSTFS_READ_TIMEOUT` | `30` | Read timeout in seconds |

### Endpoint and SSL

`RUSTFS_ENDPOINT` and `RUSTFS_USE_SSL` describe the same connection protocol and must not contradict each other.

- If `RUSTFS_ENDPOINT` includes `http://`, `RUSTFS_USE_SSL` must be `False`.
- If `RUSTFS_ENDPOINT` includes `https://`, `RUSTFS_USE_SSL` must be `True`.
- If `RUSTFS_ENDPOINT` has no scheme, `RUSTFS_USE_SSL` determines whether `http://` or `https://` is added.
- Other endpoint schemes are rejected.
- Contradictory configurations raise `ImproperlyConfigured` during storage initialization.

For example:

```python
# HTTP
RUSTFS_ENDPOINT = "http://localhost:9000"
RUSTFS_USE_SSL = False

# HTTPS
RUSTFS_ENDPOINT = "https://storage.example.com:9000"
RUSTFS_USE_SSL = True

# Scheme omitted: HTTPS is derived from RUSTFS_USE_SSL
RUSTFS_ENDPOINT = "storage.example.com:9000"
RUSTFS_USE_SSL = True
```

---

## Performance
Expand Down
38 changes: 37 additions & 1 deletion tests/test_conf.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Tests for django-rustfs settings configuration."""

import pytest
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured

from django_rustfs.conf import Settings, get_setting
from django_rustfs.conf import Settings, get_setting, resolve_endpoint


class TestSettingsCheck:
Expand Down Expand Up @@ -64,3 +66,37 @@ def test_settings_class_attributes(self):
assert Settings.PRESIGN_URLS is True
assert Settings.REDUCED_REDUNDANCY is False
assert Settings.ENCRYPTION is False

@pytest.mark.parametrize(
("endpoint", "use_ssl", "expected"),
[
("http://localhost:9000", False, "http://localhost:9000"),
("https://localhost:9000", True, "https://localhost:9000"),
("http://localhost:8080", False, "http://localhost:8080"),
("https://localhost:9443", True, "https://localhost:9443"),
("localhost:9000", False, "http://localhost:9000"),
("localhost:9443", True, "https://localhost:9443"),
],
)
def test_resolve_endpoint(self, endpoint, use_ssl, expected):
"""Endpoint scheme should follow or match the SSL setting."""
assert resolve_endpoint(endpoint, use_ssl) == expected

@pytest.mark.parametrize(
("endpoint", "use_ssl"),
[
("https://localhost:9000", False),
("http://localhost:9000", True),
],
)
def test_resolve_endpoint_rejects_contradictory_ssl(self, endpoint, use_ssl):
"""Explicit endpoint schemes must agree with RUSTFS_USE_SSL."""
with pytest.raises(
ImproperlyConfigured, match="RUSTFS_ENDPOINT and RUSTFS_USE_SSL disagree"
):
resolve_endpoint(endpoint, use_ssl)

def test_resolve_endpoint_rejects_unknown_scheme(self):
"""Only HTTP and HTTPS endpoint schemes are supported."""
with pytest.raises(ImproperlyConfigured, match="must use http:// or https://"):
resolve_endpoint("ftp://localhost:9000", False)
140 changes: 130 additions & 10 deletions tests/test_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,123 @@ def test_valid_config_initializes(self):
assert storage.secret_key == "test-secret"
assert storage.bucket_name == "django-media"

@pytest.mark.parametrize(
("endpoint_url", "use_ssl", "expected"),
[
("http://localhost:9000", False, "http://localhost:9000"),
("https://localhost:9000", True, "https://localhost:9000"),
("http://localhost:8080", False, "http://localhost:8080"),
("https://localhost:9443", True, "https://localhost:9443"),
("localhost:9000", False, "http://localhost:9000"),
("localhost:9443", True, "https://localhost:9443"),
],
)
def test_endpoint_and_ssl_are_resolved(self, endpoint_url, use_ssl, expected):
"""Storage should resolve the endpoint scheme from the SSL setting."""
storage = RustFSStorage(
endpoint_url=endpoint_url,
use_ssl=use_ssl,
access_key="test",
secret_key="test",
auto_create_bucket=False,
)
assert storage.endpoint_url == expected
assert storage.use_ssl is use_ssl

@pytest.mark.parametrize(
("endpoint_url", "use_ssl"),
[
("https://localhost:9000", False),
("http://localhost:9000", True),
],
)
def test_contradictory_endpoint_and_ssl_raises(self, endpoint_url, use_ssl):
"""Storage should reject contradictory endpoint and SSL settings."""
with pytest.raises(
ImproperlyConfigured,
match="RUSTFS_ENDPOINT and RUSTFS_USE_SSL disagree",
):
RustFSStorage(
endpoint_url=endpoint_url,
use_ssl=use_ssl,
access_key="test",
secret_key="test",
auto_create_bucket=False,
)

@pytest.mark.parametrize(
("endpoint_url", "use_ssl", "verify_ssl"),
[
("http://localhost:9000", False, True),
("https://localhost:9443", True, True),
("https://localhost:9443", True, False),
],
)
def test_boto3_client_receives_endpoint_and_tls_settings(
self, endpoint_url, use_ssl, verify_ssl
):
"""Boto3 should receive the resolved endpoint and TLS settings."""
storage = RustFSStorage(
endpoint_url=endpoint_url,
use_ssl=use_ssl,
verify_ssl=verify_ssl,
access_key="test",
secret_key="test",
auto_create_bucket=False,
connect_timeout=7,
read_timeout=42,
)
with patch("django_rustfs.storage.boto3.client") as boto3_client:
boto3_client.return_value = MagicMock()
client = storage.client

assert client is boto3_client.return_value
kwargs = boto3_client.call_args.kwargs
assert kwargs["endpoint_url"] == endpoint_url
assert kwargs["use_ssl"] is use_ssl
assert kwargs["verify"] is verify_ssl
assert kwargs["region_name"] == storage.region
config = kwargs["config"]
assert config.max_pool_connections == storage.max_pool_connections
assert config.connect_timeout == 7
assert config.read_timeout == 42

@pytest.mark.parametrize("verify_ssl", [True, False])
def test_verify_ssl_is_passed_to_boto3(self, verify_ssl):
"""VERIFY_SSL must control botocore certificate verification."""
storage = RustFSStorage(
endpoint_url="https://localhost:9443",
use_ssl=True,
verify_ssl=verify_ssl,
access_key="test",
secret_key="test",
auto_create_bucket=False,
)

with patch("django_rustfs.storage.boto3.client") as boto3_client:
boto3_client.return_value = MagicMock()
assert storage.client is boto3_client.return_value

assert boto3_client.call_args.kwargs["verify"] is verify_ssl

@pytest.mark.parametrize(
("use_ssl", "secure_urls", "expected_scheme"),
[(False, True, "http"), (True, False, "https")],
)
def test_direct_urls_follow_connection_protocol(self, use_ssl, secure_urls, expected_scheme):
"""Direct URLs should follow the canonical connection protocol."""
storage = RustFSStorage(
endpoint_url="https://localhost:9443" if use_ssl else "http://localhost:8080",
use_ssl=use_ssl,
secure_urls=secure_urls,
access_key="test",
secret_key="test",
auto_create_bucket=False,
)
storage.presign_urls = False
url = storage.url("photo.jpg")
assert url.startswith(f"{expected_scheme}://localhost:")

def test_custom_bucket_name(self):
"""Storage should accept custom bucket name."""
storage = RustFSStorage(
Expand Down Expand Up @@ -177,19 +294,22 @@ def test_size(self, storage):
storage.client.head_object.return_value = {"ContentLength": 1024}
assert storage.size("photo.jpg") == 1024

def test_url_with_custom_domain(self, storage):
"""Test URL generation with custom domain."""
@pytest.mark.parametrize(
("use_ssl", "secure_urls", "expected_scheme"),
[(False, True, "http"), (True, False, "https")],
)
def test_url_with_custom_domain_follows_connection_protocol(
self, storage, use_ssl, secure_urls, expected_scheme
):
"""Custom-domain URLs should follow the canonical connection protocol."""
storage.endpoint_url = "https://localhost:9443" if use_ssl else "http://localhost:8080"
storage.use_ssl = use_ssl
storage.secure_urls = secure_urls
storage.custom_domain = "cdn.example.com"
storage.secure_urls = True
url = storage.url("photo.jpg")
assert url == "https://cdn.example.com/photo.jpg"

def test_url_with_custom_domain_insecure(self, storage):
"""Test URL generation with custom domain and HTTP."""
storage.custom_domain = "cdn.example.com"
storage.secure_urls = False
url = storage.url("photo.jpg")
assert url == "http://cdn.example.com/photo.jpg"

assert url == f"{expected_scheme}://cdn.example.com/photo.jpg"

def test_url_presigned(self, storage):
"""Test presigned URL generation."""
Expand Down
Loading