Browse Source

add openlist/webdav backend

codex/vue-opencloud-design-system
Rain Mark 3 months ago
parent
commit
22fd71cbb1
  1. 3
      .gitignore
  2. 30
      README.md
  3. 34
      alembic/versions/20260413_0002_file_recycle_bin.py
  4. 42
      alembic/versions/20260413_0003_auth_sessions.py
  5. 43
      alembic/versions/20260414_0004_placement_policies.py
  6. 52
      alembic/versions/20260417_0007_baseline_schema.py
  7. 18
      app/adapters/storage/base.py
  8. 10
      app/adapters/storage/local.py
  9. 18
      app/adapters/storage/registry.py
  10. 27
      app/adapters/storage/s3.py
  11. 145
      app/adapters/storage/webdav.py
  12. 55
      app/api/routes/backends.py
  13. 34
      app/api/routes/files.py
  14. 7
      app/api/routes/policies.py
  15. 130
      app/core/backend_config.py
  16. 2
      app/core/config.py
  17. 50
      app/core/secret_codec.py
  18. 42
      app/core/storage_layout.py
  19. 3
      app/models/entities.py
  20. 25
      app/repositories/backend_repository.py
  21. 17
      app/repositories/blob_replica_repository.py
  22. 12
      app/schemas/backend.py
  23. 33
      app/schemas/file.py
  24. 1
      app/schemas/policy.py
  25. 140
      app/services/backend_service.py
  26. 14
      app/services/file_service.py
  27. 16
      app/services/local_storage_service.py
  28. 6
      app/services/policy_service.py
  29. 26
      app/services/storage_service.py
  30. 2
      app/web/dist/assets/app.css
  31. 30
      app/web/dist/assets/app.js
  32. 10
      deploy/iron/iron.env.example
  33. 29
      deploy/iron/start_iron.sh
  34. 3
      deploy/openlist/.env.example
  35. 9
      deploy/openlist/docker-compose.yml
  36. 17
      deploy/systemd/iron.service.example
  37. 1
      docs/README.md
  38. 24
      docs/api.md
  39. 42
      docs/architecture.md
  40. BIN
      docs/assets/iron-demo.gif
  41. 25
      docs/development.md
  42. 487
      docs/openlist-local-deployment-sop.md
  43. 11
      docs/product.md
  44. 5
      frontend/src/app/router.tsx
  45. 4
      frontend/src/components/layout/AppLayout.tsx
  46. 129
      frontend/src/lib/api.ts
  47. 13
      frontend/src/lib/format.ts
  48. 120
      frontend/src/routes/FilesPage.tsx
  49. 290
      frontend/src/routes/PoliciesPage.tsx
  50. 441
      frontend/src/routes/StoragePage.tsx
  51. 186
      frontend/src/styles/app.css
  52. 2
      pyproject.toml
  53. 155
      scripts/generate_readme_demo.py
  54. 320
      scripts/real_openlist_acceptance.py
  55. 173
      scripts/real_openlist_chaos_acceptance.py
  56. 208
      scripts/real_openlist_multi_backend_acceptance.py
  57. 154
      scripts/real_openlist_multi_backend_chaos.py
  58. 40
      scripts/real_openlist_suite.py
  59. 15
      scripts/ui_playwright_smoke.py
  60. 99
      tests/conftest.py
  61. 203
      tests/test_backend_routes.py
  62. 2
      tests/test_config.py
  63. 13
      tests/test_file_routes.py
  64. 55
      tests/test_job_routes.py
  65. 2
      tests/test_migrations.py
  66. 7
      tests/test_policy_routes.py
  67. 56
      tests/test_storage_layout.py
  68. 2
      tests/test_upload_routes.py
  69. 63
      tests/test_webdav_adapter.py

3
.gitignore

@ -12,3 +12,6 @@ node_modules/
output/
.iron-storage/
.iron-temp/
.iron-cache/
deploy/openlist/.env
deploy/iron/iron.env

30
README.md

@ -8,6 +8,8 @@ The project is currently a **Product MVP Candidate**: the core backend and Web
app exist, but the release still needs hardening, broader browser regression
coverage, and more real-world usage.
![Iron demo](docs/assets/iron-demo.gif)
## Features
- FastAPI backend with SQLite, SQLAlchemy, and Alembic migrations
@ -16,10 +18,12 @@ coverage, and more real-world usage.
restore, download, and preview
- `tus` upload flow with local object persistence
- Desktop-first React Web app at `/app`
- Storage backend management for local storage and S3 runtime paths
- Storage backend management for local directories, S3 storage, and
OpenList/WebDAV runtime paths
- Placement policies, reconcile jobs, health checks, and retryable background jobs
- Metadata export, validation, integrity checks, restore plans, and guarded import
- Python Playwright end-to-end coverage for core browser flows
- Real-environment OpenList acceptance scripts for deployment validation
## Quick Start
@ -46,6 +50,9 @@ username: admin
password: changeme-iron
```
For a practical local deployment with OpenList and Aliyun Drive, start with
[docs/openlist-local-deployment-sop.md](docs/openlist-local-deployment-sop.md).
## Configuration
Common environment variables:
@ -53,6 +60,7 @@ Common environment variables:
- `IRON_DATABASE_URL`
- `IRON_BOOTSTRAP_USERNAME`
- `IRON_BOOTSTRAP_PASSWORD`
- `IRON_SECRET_KEY`
- `IRON_AUTH_SESSION_TTL_HOURS`
- `IRON_UPLOAD_TEMP_DIR`
- `IRON_LOCAL_STORAGE_DIR`
@ -63,6 +71,10 @@ Common environment variables:
Runtime data is intentionally ignored by Git. By default, local development may
create `.iron-storage/`, `.iron-temp/`, `iron.db`, and `output/`.
`IRON_SECRET_KEY` encrypts stored backend credentials such as S3 keys and
WebDAV passwords. Override the development default before using real storage
credentials.
## Development
Build the frontend:
@ -80,7 +92,13 @@ Run the full test suite:
Current expected result:
```text
66 passed
78 passed
```
Run the real OpenList acceptance suite against a live local deployment:
```bash
.venv/bin/python scripts/real_openlist_suite.py
```
Run the browser E2E flow directly:
@ -89,6 +107,12 @@ Run the browser E2E flow directly:
.venv/bin/python scripts/ui_playwright_smoke.py
```
Regenerate the README demo GIF:
```bash
.venv/bin/python scripts/generate_readme_demo.py
```
Or through pytest:
```bash
@ -105,6 +129,7 @@ alembic/ Database migrations
frontend/ Vite + React + TypeScript source
app/web/dist/ Built frontend assets served by FastAPI
scripts/ Local automation and E2E scripts
deploy/ OpenList compose, Iron env templates, and systemd examples
tests/ Backend, API, and browser E2E tests
docs/ Product, architecture, API, and maintenance docs
```
@ -118,6 +143,7 @@ Start with:
- [Architecture](docs/architecture.md)
- [API](docs/api.md)
- [Development](docs/development.md)
- [OpenList + Iron Local Deployment SOP](docs/openlist-local-deployment-sop.md)
For contribution workflow, see [CONTRIBUTING.md](CONTRIBUTING.md).
For AI/coding-agent guidance, see [AGENTS.md](AGENTS.md).

34
alembic/versions/20260413_0002_file_recycle_bin.py

@ -1,34 +0,0 @@
"""add file recycle bin fields
Revision ID: 20260413_0002
Revises: 20260413_0001
Create Date: 2026-04-13 00:20:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "20260413_0002"
down_revision = "20260413_0001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_index("idx_file_entries_directory_name", table_name="file_entries")
op.create_index(
"idx_file_entries_directory_name",
"file_entries",
["directory_id", "name", "is_deleted"],
unique=True,
)
op.add_column("file_entries", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
op.create_index("idx_file_entries_deleted_at", "file_entries", ["deleted_at"])
def downgrade() -> None:
op.drop_index("idx_file_entries_deleted_at", table_name="file_entries")
op.drop_column("file_entries", "deleted_at")
op.drop_index("idx_file_entries_directory_name", table_name="file_entries")
op.create_index("idx_file_entries_directory_name", "file_entries", ["directory_id", "name"], unique=True)

42
alembic/versions/20260413_0003_auth_sessions.py

@ -1,42 +0,0 @@
"""add auth sessions
Revision ID: 20260413_0003
Revises: 20260413_0002
Create Date: 2026-04-13 20:10:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "20260413_0003"
down_revision = "20260413_0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"auth_sessions",
sa.Column("id", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("token_hash", sa.String(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"idx_auth_sessions_user_status",
"auth_sessions",
["user_id", "revoked_at", "expires_at"],
)
op.create_index("idx_auth_sessions_token_hash", "auth_sessions", ["token_hash"], unique=True)
def downgrade() -> None:
op.drop_index("idx_auth_sessions_token_hash", table_name="auth_sessions")
op.drop_index("idx_auth_sessions_user_status", table_name="auth_sessions")
op.drop_table("auth_sessions")

43
alembic/versions/20260414_0004_placement_policies.py

@ -1,43 +0,0 @@
"""add placement policies
Revision ID: 20260414_0004
Revises: 20260413_0003
Create Date: 2026-04-14 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "20260414_0004"
down_revision = "20260413_0003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"placement_policies",
sa.Column("id", sa.String(), nullable=False),
sa.Column("file_class", sa.String(), nullable=False),
sa.Column("require_local", sa.Boolean(), server_default="1", nullable=False),
sa.Column("stable_replica_count", sa.Integer(), server_default="1", nullable=False),
sa.Column("opportunistic_replica_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("preferred_backend_ids_json", sa.Text(), server_default="[]", nullable=False),
sa.Column("excluded_backend_ids_json", sa.Text(), server_default="[]", nullable=False),
sa.Column("max_non_local_size_bytes", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"idx_placement_policies_file_class",
"placement_policies",
["file_class"],
unique=True,
)
def downgrade() -> None:
op.drop_index("idx_placement_policies_file_class", table_name="placement_policies")
op.drop_table("placement_policies")

52
alembic/versions/20260413_0001_initial_schema.py → alembic/versions/20260417_0007_baseline_schema.py

@ -1,8 +1,8 @@
"""initial schema
"""baseline schema
Revision ID: 20260413_0001
Revision ID: 20260417_0007
Revises:
Create Date: 2026-04-13 00:00:00.000000
Create Date: 2026-04-17 10:30:00.000000
"""
from alembic import op
@ -10,7 +10,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "20260413_0001"
revision = "20260417_0007"
down_revision = None
branch_labels = None
depends_on = None
@ -28,6 +28,21 @@ def upgrade() -> None:
sa.UniqueConstraint("username"),
)
op.create_table(
"auth_sessions",
sa.Column("id", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("token_hash", sa.String(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_auth_sessions_user_status", "auth_sessions", ["user_id", "revoked_at", "expires_at"])
op.create_index("idx_auth_sessions_token_hash", "auth_sessions", ["token_hash"], unique=True)
op.create_table(
"directories",
sa.Column("id", sa.String(), nullable=False),
@ -53,7 +68,8 @@ def upgrade() -> None:
sa.Column("read_priority", sa.Integer(), nullable=False),
sa.Column("write_priority", sa.Integer(), nullable=False),
sa.Column("is_enabled", sa.Boolean(), server_default="1", nullable=False),
sa.Column("config_json", sa.Text(), nullable=False),
sa.Column("public_config_json", sa.Text(), nullable=False),
sa.Column("secret_config_json", sa.Text(), nullable=False),
sa.Column("capacity_hint_bytes", sa.Integer(), nullable=True),
sa.Column("last_health_status", sa.String(), nullable=True),
sa.Column("last_health_checked_at", sa.DateTime(timezone=True), nullable=True),
@ -65,6 +81,22 @@ def upgrade() -> None:
op.create_index("idx_backends_enabled", "backends", ["is_enabled"])
op.create_index("idx_backends_priority", "backends", ["write_priority", "read_priority"])
op.create_table(
"placement_policies",
sa.Column("id", sa.String(), nullable=False),
sa.Column("file_class", sa.String(), nullable=False),
sa.Column("require_local", sa.Boolean(), server_default="1", nullable=False),
sa.Column("stable_replica_count", sa.Integer(), server_default="1", nullable=False),
sa.Column("opportunistic_replica_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("preferred_backend_ids_json", sa.Text(), server_default="[]", nullable=False),
sa.Column("excluded_backend_ids_json", sa.Text(), server_default="[]", nullable=False),
sa.Column("max_non_local_size_bytes", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_placement_policies_file_class", "placement_policies", ["file_class"], unique=True)
op.create_table(
"upload_sessions",
sa.Column("id", sa.String(), nullable=False),
@ -93,12 +125,14 @@ def upgrade() -> None:
sa.Column("size_bytes", sa.Integer(), nullable=False),
sa.Column("current_version_id", sa.String(), nullable=True),
sa.Column("is_deleted", sa.Boolean(), server_default="0", nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["directory_id"], ["directories.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_file_entries_current_version_id", "file_entries", ["current_version_id"])
op.create_index("idx_file_entries_deleted_at", "file_entries", ["deleted_at"])
op.create_index(
"idx_file_entries_directory_name",
"file_entries",
@ -258,6 +292,9 @@ def downgrade() -> None:
op.drop_index("idx_blob_replicas_backend_status", table_name="blob_replicas")
op.drop_table("blob_replicas")
op.drop_index("idx_placement_policies_file_class", table_name="placement_policies")
op.drop_table("placement_policies")
op.drop_index("idx_blobs_file_version_index", table_name="blobs")
op.drop_index("idx_blobs_content_hash", table_name="blobs")
op.drop_table("blobs")
@ -266,6 +303,7 @@ def downgrade() -> None:
op.drop_index("idx_file_versions_content_hash", table_name="file_versions")
op.drop_table("file_versions")
op.drop_index("idx_file_entries_deleted_at", table_name="file_entries")
op.drop_index("idx_file_entries_directory_name", table_name="file_entries")
op.drop_index("idx_file_entries_current_version_id", table_name="file_entries")
op.drop_table("file_entries")
@ -282,4 +320,8 @@ def downgrade() -> None:
op.drop_index("idx_directories_parent_id", table_name="directories")
op.drop_table("directories")
op.drop_index("idx_auth_sessions_token_hash", table_name="auth_sessions")
op.drop_index("idx_auth_sessions_user_status", table_name="auth_sessions")
op.drop_table("auth_sessions")
op.drop_table("users")

18
app/adapters/storage/base.py

@ -0,0 +1,18 @@
from __future__ import annotations
from pathlib import Path
from typing import Protocol
class StorageAdapter(Protocol):
async def check(self) -> dict[str, str]:
...
async def put_file(self, source_path: str, storage_key: str) -> dict[str, str | None]:
...
async def download_file(self, storage_key: str, destination_path: Path) -> Path:
...
async def object_exists(self, storage_key: str) -> bool:
...

10
app/adapters/storage/local.py

@ -21,7 +21,7 @@ class LocalStorageAdapter:
raise NotADirectoryError(str(path))
return {"status": "healthy", "detail": str(path)}
async def put_file(self, source_path: str, storage_key: str) -> str:
async def put_file(self, source_path: str, storage_key: str) -> dict[str, str | None]:
destination = self.base_path / storage_key
await anyio.to_thread.run_sync(lambda: destination.parent.mkdir(parents=True, exist_ok=True))
@ -30,7 +30,13 @@ class LocalStorageAdapter:
shutil.copyfile(source_path, destination)
await anyio.to_thread.run_sync(_copy_if_needed)
return str(destination)
return {"etag": None, "storage_key": storage_key}
async def download_file(self, storage_key: str, destination_path: Path) -> Path:
source = await self.resolve_path(storage_key)
await anyio.to_thread.run_sync(lambda: destination_path.parent.mkdir(parents=True, exist_ok=True))
await anyio.to_thread.run_sync(lambda: shutil.copyfile(source, destination_path))
return destination_path
async def resolve_path(self, storage_key: str) -> Path:
destination = self.base_path / storage_key

18
app/adapters/storage/registry.py

@ -0,0 +1,18 @@
from __future__ import annotations
from typing import Any
from app.adapters.storage.base import StorageAdapter
from app.adapters.storage.local import LocalStorageAdapter
from app.adapters.storage.s3 import S3StorageAdapter
from app.adapters.storage.webdav import WebDAVStorageAdapter
def build_storage_adapter(backend_type: str, config: dict[str, Any]) -> StorageAdapter:
if backend_type == "local_directory":
return LocalStorageAdapter(config["base_path"])
if backend_type == "s3":
return S3StorageAdapter(config)
if backend_type == "webdav":
return WebDAVStorageAdapter(config)
raise ValueError(f"Unsupported backend type: {backend_type}")

27
app/adapters/storage/s3.py

@ -5,6 +5,7 @@ from typing import Any
import anyio
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
@ -23,31 +24,38 @@ class S3StorageAdapter:
async def put_file(self, source_path: str, storage_key: str) -> dict[str, str | None]:
client = self._create_client()
object_key = self._object_key(storage_key)
def _put() -> dict[str, str | None]:
extra_args: dict[str, Any] = {}
client.upload_file(source_path, self.config["bucket"], storage_key, ExtraArgs=extra_args)
metadata = client.head_object(Bucket=self.config["bucket"], Key=storage_key)
if self.config.get("storage_class"):
extra_args["StorageClass"] = self.config["storage_class"]
if self.config.get("server_side_encryption"):
extra_args["ServerSideEncryption"] = self.config["server_side_encryption"]
client.upload_file(source_path, self.config["bucket"], object_key, ExtraArgs=extra_args)
metadata = client.head_object(Bucket=self.config["bucket"], Key=object_key)
return {"etag": metadata.get("ETag"), "storage_key": storage_key}
return await anyio.to_thread.run_sync(_put)
async def download_file(self, storage_key: str, destination_path: Path) -> Path:
client = self._create_client()
object_key = self._object_key(storage_key)
def _download() -> Path:
destination_path.parent.mkdir(parents=True, exist_ok=True)
client.download_file(self.config["bucket"], storage_key, str(destination_path))
client.download_file(self.config["bucket"], object_key, str(destination_path))
return destination_path
return await anyio.to_thread.run_sync(_download)
async def object_exists(self, storage_key: str) -> bool:
client = self._create_client()
object_key = self._object_key(storage_key)
def _exists() -> bool:
try:
client.head_object(Bucket=self.config["bucket"], Key=storage_key)
client.head_object(Bucket=self.config["bucket"], Key=object_key)
return True
except ClientError as exc:
error_code = exc.response.get("Error", {}).get("Code")
@ -58,12 +66,23 @@ class S3StorageAdapter:
return await anyio.to_thread.run_sync(_exists)
def _create_client(self):
client_config = None
if self.config.get("force_path_style"):
client_config = Config(s3={"addressing_style": "path"})
client_kwargs = {
"service_name": "s3",
"region_name": self.config.get("region"),
"endpoint_url": self.config.get("endpoint_url"),
"aws_access_key_id": self.config.get("access_key_id"),
"aws_secret_access_key": self.config.get("secret_access_key"),
"verify": self.config.get("verify_ssl", True),
"config": client_config,
}
cleaned_kwargs = {key: value for key, value in client_kwargs.items() if value is not None}
return boto3.client(**cleaned_kwargs)
def _object_key(self, storage_key: str) -> str:
prefix = (self.config.get("prefix") or "").strip("/")
if not prefix:
return storage_key
return f"{prefix}/{storage_key}"

145
app/adapters/storage/webdav.py

@ -0,0 +1,145 @@
from __future__ import annotations
import base64
import ssl
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
import anyio
DAV_NAMESPACE = {"d": "DAV:"}
class WebDAVStorageAdapter:
def __init__(self, config: dict[str, Any]) -> None:
self.config = config
self.base_url = config["endpoint_url"].rstrip("/")
self.root_path = self._normalize_relative_path(config.get("root_path") or "iron")
self._ssl_context = None if config.get("verify_ssl", True) else ssl._create_unverified_context()
async def check(self) -> dict[str, str]:
def _check() -> dict[str, str]:
self._ensure_directory(self.root_path)
return {"status": "healthy", "detail": self.root_path}
return await anyio.to_thread.run_sync(_check)
async def put_file(self, source_path: str, storage_key: str) -> dict[str, str | None]:
def _put() -> dict[str, str | None]:
source = Path(source_path)
if not source.exists():
raise FileNotFoundError(source_path)
normalized_key = self._normalize_relative_path(storage_key)
segments = normalized_key.split("/")
if len(segments) > 1:
self._ensure_directory(f"{self.root_path}/{'/'.join(segments[:-1])}")
request = urllib.request.Request(
self._build_url(f"{self.root_path}/{normalized_key}"),
data=source.read_bytes(),
method="PUT",
headers=self._auth_headers(),
)
with urllib.request.urlopen(request, timeout=60, context=self._ssl_context) as response:
etag = response.headers.get("ETag")
return {"etag": etag, "storage_key": normalized_key}
return await anyio.to_thread.run_sync(_put)
async def download_file(self, storage_key: str, destination_path: Path) -> Path:
def _download() -> Path:
normalized_key = self._normalize_relative_path(storage_key)
request = urllib.request.Request(
self._build_url(f"{self.root_path}/{normalized_key}"),
method="GET",
headers=self._auth_headers(),
)
try:
with urllib.request.urlopen(request, timeout=60, context=self._ssl_context) as response:
body = response.read()
except urllib.error.HTTPError as exc:
if exc.code == 404:
raise FileNotFoundError(storage_key) from exc
raise
destination_path.parent.mkdir(parents=True, exist_ok=True)
destination_path.write_bytes(body)
return destination_path
return await anyio.to_thread.run_sync(_download)
async def object_exists(self, storage_key: str) -> bool:
def _exists() -> bool:
normalized_key = self._normalize_relative_path(storage_key)
request = urllib.request.Request(
self._build_url(f"{self.root_path}/{normalized_key}"),
method="HEAD",
headers=self._auth_headers(),
)
try:
with urllib.request.urlopen(request, timeout=20, context=self._ssl_context):
return True
except urllib.error.HTTPError as exc:
if exc.code == 404:
return False
if exc.code in {403, 405}:
return self._path_exists(f"{self.root_path}/{normalized_key}")
raise
return await anyio.to_thread.run_sync(_exists)
def _ensure_directory(self, relative_path: str) -> None:
current = ""
for segment in self._normalize_relative_path(relative_path).split("/"):
current = f"{current}/{segment}".strip("/")
if self._path_exists(current):
continue
request = urllib.request.Request(
self._build_url(current),
method="MKCOL",
headers=self._auth_headers(),
)
try:
with urllib.request.urlopen(request, timeout=20, context=self._ssl_context):
pass
except urllib.error.HTTPError as exc:
if exc.code not in {201, 405}:
raise RuntimeError(f"WebDAV MKCOL failed with {exc.code} for {current}") from exc
def _path_exists(self, relative_path: str) -> bool:
request = urllib.request.Request(
self._build_url(relative_path),
method="PROPFIND",
headers={**self._auth_headers(), "Depth": "0"},
)
try:
with urllib.request.urlopen(request, timeout=20, context=self._ssl_context) as response:
body = response.read()
except urllib.error.HTTPError as exc:
if exc.code == 404:
return False
raise RuntimeError(f"WebDAV PROPFIND failed with {exc.code} for {relative_path}") from exc
if not body:
return True
root = ET.fromstring(body)
return root.find("d:response", DAV_NAMESPACE) is not None
def _build_url(self, relative_path: str) -> str:
safe_path = "/".join(urllib.parse.quote(part, safe="") for part in relative_path.strip("/").split("/") if part)
return f"{self.base_url}/{safe_path}"
def _auth_headers(self) -> dict[str, str]:
username = str(self.config.get("username") or "")
password = str(self.config.get("password") or "")
token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
return {"Authorization": f"Basic {token}"}
@staticmethod
def _normalize_relative_path(value: str) -> str:
normalized = "/".join(part for part in value.strip().strip("/").split("/") if part)
if not normalized:
raise ValueError("path must not be empty")
return normalized

55
app/api/routes/backends.py

@ -1,6 +1,11 @@
import json
from fastapi import APIRouter, Depends
from fastapi import Response
from app.api.dependencies import get_backend_service
from app.core.secret_codec import list_secret_fields
from app.models.entities import Backend
from app.schemas.backend import (
BackendCheckResponse,
BackendListResponse,
@ -8,19 +13,28 @@ from app.schemas.backend import (
BackendSummary,
CreateBackendRequest,
CreateBackendResponse,
UpdateBackendRequest,
)
from app.services.backend_service import BackendService
router = APIRouter()
def summarize_backend(backend: Backend) -> BackendSummary:
summary = BackendSummary.model_validate(backend, from_attributes=True)
public_config = json.loads(backend.public_config_json)
summary.config = public_config
summary.secret_fields = list_secret_fields(backend.secret_config_json)
return summary
@router.get("", response_model=BackendListResponse)
async def list_backends(
service: BackendService = Depends(get_backend_service),
) -> BackendListResponse:
items = await service.list_backends()
return BackendListResponse(
items=[BackendSummary.model_validate(item, from_attributes=True) for item in items]
items=[summarize_backend(item) for item in items]
)
@ -37,7 +51,7 @@ async def create_backend(
write_priority=payload.write_priority,
config=payload.config,
)
return CreateBackendResponse(backend=BackendSummary.model_validate(backend, from_attributes=True))
return CreateBackendResponse(backend=summarize_backend(backend))
@router.post("/{backend_id}/check", response_model=BackendCheckResponse)
@ -54,10 +68,45 @@ async def check_backend(
)
@router.patch("/{backend_id}", response_model=BackendMutationResponse)
async def update_backend(
backend_id: str,
payload: UpdateBackendRequest,
service: BackendService = Depends(get_backend_service),
) -> BackendMutationResponse:
backend = await service.update_backend(
backend_id,
name=payload.name,
stability_class=payload.stability_class,
read_priority=payload.read_priority,
write_priority=payload.write_priority,
config=payload.config,
)
return BackendMutationResponse(backend=summarize_backend(backend))
@router.post("/{backend_id}/disable", response_model=BackendMutationResponse)
async def disable_backend(
backend_id: str,
service: BackendService = Depends(get_backend_service),
) -> BackendMutationResponse:
backend = await service.disable_backend(backend_id)
return BackendMutationResponse(backend=BackendSummary.model_validate(backend, from_attributes=True))
return BackendMutationResponse(backend=summarize_backend(backend))
@router.post("/{backend_id}/enable", response_model=BackendMutationResponse)
async def enable_backend(
backend_id: str,
service: BackendService = Depends(get_backend_service),
) -> BackendMutationResponse:
backend = await service.enable_backend(backend_id)
return BackendMutationResponse(backend=summarize_backend(backend))
@router.delete("/{backend_id}", status_code=204)
async def delete_backend(
backend_id: str,
service: BackendService = Depends(get_backend_service),
) -> Response:
await service.delete_backend(backend_id)
return Response(status_code=204)

34
app/api/routes/files.py

@ -8,7 +8,9 @@ from app.api.dependencies import get_file_service, get_repair_service
from app.schemas.file import (
DeletedFileSummary,
FileDetailResponse,
FilePlacementBackendSummary,
FileMutationResponse,
FileReplicaSummary,
FileSummary,
MoveFileRequest,
PreviewArtifactSummary,
@ -84,12 +86,42 @@ async def get_file_detail(
file_id: str,
service: FileService = Depends(get_file_service),
) -> FileDetailResponse:
file_entry, artifacts = await service.get_file_detail(file_id)
file_entry, artifacts, replicas, decision = await service.get_file_detail(file_id)
replica_items = [
FileReplicaSummary(
id=replica.id,
blob_id=replica.blob_id,
backend_id=backend.id,
backend_name=backend.name,
backend_type=backend.type,
backend_stability_class=backend.stability_class,
backend_enabled=backend.is_enabled,
storage_key=replica.storage_key,
status=replica.status,
size_bytes=replica.size_bytes,
checksum=replica.checksum,
etag=replica.etag,
last_verified_at=replica.last_verified_at,
created_at=replica.created_at,
updated_at=replica.updated_at,
)
for replica, backend in replicas
]
ready_replica_backend_ids = sorted({item.backend_id for item in replica_items if item.status == "ready"})
desired_backend_ids = [backend.id for backend in (decision.backends if decision is not None else [])]
return FileDetailResponse(
file=FileSummary.model_validate(file_entry, from_attributes=True),
preview_artifacts=[
PreviewArtifactSummary.model_validate(artifact, from_attributes=True) for artifact in artifacts
],
replicas=replica_items,
placement_file_class=decision.file_class if decision is not None else None,
desired_backends=[
FilePlacementBackendSummary.model_validate(backend, from_attributes=True)
for backend in (decision.backends if decision is not None else [])
],
ready_replica_backend_ids=ready_replica_backend_ids,
missing_backend_ids=[backend_id for backend_id in desired_backend_ids if backend_id not in ready_replica_backend_ids],
)

7
app/api/routes/policies.py

@ -3,7 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends
from fastapi import Query
from app.api.dependencies import get_policy_service
from app.api.dependencies import get_policy_service, get_repair_service
from app.schemas.policy import (
PlacementDecisionBackendSummary,
PlacementDecisionResponse,
@ -13,6 +13,7 @@ from app.schemas.policy import (
UpsertPlacementPolicyRequest,
)
from app.services.policy_service import PolicyService
from app.services.repair_service import RepairService
router = APIRouter()
@ -65,6 +66,7 @@ async def upsert_placement_policy(
file_class: str,
payload: UpsertPlacementPolicyRequest,
service: PolicyService = Depends(get_policy_service),
repair_service: RepairService = Depends(get_repair_service),
) -> PlacementPolicyMutationResponse:
policy = await service.upsert_policy(
file_class=file_class,
@ -75,4 +77,5 @@ async def upsert_placement_policy(
excluded_backend_ids=payload.excluded_backend_ids,
max_non_local_size_bytes=payload.max_non_local_size_bytes,
)
return PlacementPolicyMutationResponse(policy=_policy_to_summary(policy))
enqueued_jobs = await repair_service.enqueue_full_reconcile()
return PlacementPolicyMutationResponse(policy=_policy_to_summary(policy), reconcile_enqueued_jobs=enqueued_jobs)

130
app/core/backend_config.py

@ -0,0 +1,130 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field, ValidationError as PydanticValidationError, field_validator
from app.core.exceptions import ValidationError
BackendType = Literal["local_directory", "s3", "webdav"]
class BackendConfigError(ValidationError):
code = "backend_config_invalid"
message = "Backend configuration is invalid."
class LocalDirectoryConfig(BaseModel):
base_path: str = Field(min_length=1)
@field_validator("base_path")
@classmethod
def clean_base_path(cls, value: str) -> str:
cleaned = value.strip()
if not cleaned:
raise ValueError("base_path must not be empty")
return cleaned
class S3CompatibleConfig(BaseModel):
bucket: str = Field(min_length=1)
region: str | None = None
endpoint_url: str | None = None
access_key_id: str | None = None
secret_access_key: str | None = None
prefix: str = ""
force_path_style: bool = False
verify_ssl: bool = True
storage_class: str | None = None
server_side_encryption: str | None = None
@field_validator("bucket")
@classmethod
def clean_bucket(cls, value: str) -> str:
cleaned = value.strip()
if not cleaned:
raise ValueError("bucket must not be empty")
return cleaned
@field_validator("prefix")
@classmethod
def clean_prefix(cls, value: str) -> str:
return value.strip().strip("/")
class WebDAVConfig(BaseModel):
endpoint_url: str = Field(min_length=1)
username: str = Field(min_length=1)
password: str | None = None
root_path: str = Field(default="iron", min_length=1)
verify_ssl: bool = True
@field_validator("endpoint_url", "username")
@classmethod
def clean_required_text(cls, value: str) -> str:
cleaned = value.strip()
if not cleaned:
raise ValueError("value must not be empty")
return cleaned
@field_validator("endpoint_url")
@classmethod
def clean_endpoint_url(cls, value: str) -> str:
return value.strip().rstrip("/")
@field_validator("root_path")
@classmethod
def clean_root_path(cls, value: str) -> str:
cleaned = "/".join(part for part in value.strip().strip("/").split("/") if part)
if not cleaned:
raise ValueError("root_path must not be empty")
return cleaned
CONFIG_MODELS: dict[str, type[BaseModel]] = {
"local_directory": LocalDirectoryConfig,
"s3": S3CompatibleConfig,
"webdav": WebDAVConfig,
}
SECRET_CONFIG_KEYS = {
"access_key_id",
"secret_access_key",
"password",
}
def supported_backend_types() -> set[str]:
return set(CONFIG_MODELS)
def validate_backend_config(backend_type: str, config: dict[str, Any]) -> dict[str, Any]:
model = CONFIG_MODELS.get(backend_type)
if model is None:
raise BackendConfigError(f"Unsupported backend type: {backend_type}.")
try:
return model.model_validate(config).model_dump()
except PydanticValidationError as exc:
first_error = exc.errors()[0] if exc.errors() else {}
location = ".".join(str(part) for part in first_error.get("loc", []))
message = first_error.get("msg", "Invalid backend configuration.")
prefix = f"{location}: " if location else ""
raise BackendConfigError(prefix + message) from exc
def split_backend_config(backend_type: str, config: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
normalized = validate_backend_config(backend_type, config)
public_config: dict[str, Any] = {}
secret_config: dict[str, Any] = {}
for key, value in normalized.items():
if key in SECRET_CONFIG_KEYS:
if value:
secret_config[key] = value
else:
public_config[key] = value
return public_config, secret_config
def merge_backend_config(public_config: dict[str, Any], secret_config: dict[str, Any]) -> dict[str, Any]:
return {**public_config, **secret_config}

2
app/core/config.py

@ -21,6 +21,7 @@ class Settings:
cache_dir: str = "./.iron-cache"
bootstrap_username: str = "admin"
bootstrap_password: str = "changeme-iron"
secret_key: str = "iron-local-development-secret-key"
auth_session_ttl_hours: int = 24 * 7
job_poll_interval_seconds: float = 2.0
job_batch_size: int = 10
@ -37,6 +38,7 @@ class Settings:
cache_dir=os.getenv("IRON_CACHE_DIR", cls.cache_dir),
bootstrap_username=os.getenv("IRON_BOOTSTRAP_USERNAME", cls.bootstrap_username),
bootstrap_password=os.getenv("IRON_BOOTSTRAP_PASSWORD", cls.bootstrap_password),
secret_key=os.getenv("IRON_SECRET_KEY", cls.secret_key),
auth_session_ttl_hours=int(
os.getenv("IRON_AUTH_SESSION_TTL_HOURS", str(cls.auth_session_ttl_hours))
),

50
app/core/secret_codec.py

@ -0,0 +1,50 @@
from __future__ import annotations
import base64
import hashlib
import json
from typing import Any
from cryptography.fernet import Fernet, InvalidToken
from app.core.config import get_settings
from app.core.exceptions import ValidationError
SECRET_PREFIX = "fernet:v1:"
class SecretConfigError(ValidationError):
code = "secret_config_invalid"
message = "Secret configuration cannot be decrypted."
def encrypt_secret_config(secret_config: dict[str, Any]) -> str:
payload = json.dumps(secret_config, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
token = _fernet().encrypt(payload).decode("utf-8")
return SECRET_PREFIX + token
def decrypt_secret_config(raw_value: str) -> dict[str, Any]:
if not raw_value:
return {}
if not raw_value.startswith(SECRET_PREFIX):
value = json.loads(raw_value)
return value if isinstance(value, dict) else {}
token = raw_value.removeprefix(SECRET_PREFIX).encode("utf-8")
try:
payload = _fernet().decrypt(token)
except InvalidToken as exc:
raise SecretConfigError() from exc
value = json.loads(payload.decode("utf-8"))
return value if isinstance(value, dict) else {}
def list_secret_fields(raw_value: str) -> list[str]:
return sorted(decrypt_secret_config(raw_value))
def _fernet() -> Fernet:
digest = hashlib.sha256(get_settings().secret_key.encode("utf-8")).digest()
key = base64.urlsafe_b64encode(digest)
return Fernet(key)

42
app/core/storage_layout.py

@ -0,0 +1,42 @@
from __future__ import annotations
from datetime import datetime
from app.models.entities import Blob
def build_blob_storage_key(blob: Blob) -> str:
content_hash = blob.content_hash
prefix_one = content_hash[:2] if len(content_hash) >= 2 else "xx"
prefix_two = content_hash[2:4] if len(content_hash) >= 4 else "xx"
kind = blob.kind or "blob"
return f"objects/{kind}/sha256/{prefix_one}/{prefix_two}/{content_hash}.blob"
def build_preview_artifact_storage_key(*, artifact_type: str, blob: Blob) -> str:
content_hash = blob.content_hash
prefix_one = content_hash[:2] if len(content_hash) >= 2 else "xx"
prefix_two = content_hash[2:4] if len(content_hash) >= 4 else "xx"
artifact_segment = _normalize_segment(artifact_type)
return f"objects/preview/{artifact_segment}/sha256/{prefix_one}/{prefix_two}/{content_hash}.blob"
def build_export_snapshot_storage_key(*, exported_at: datetime, digest: str) -> str:
year = f"{exported_at.year:04d}"
month = f"{exported_at.month:02d}"
day = f"{exported_at.day:02d}"
normalized_digest = _normalize_segment(digest) or "snapshot"
return f"objects/export/metadata/{year}/{month}/{day}/{normalized_digest}.json"
def build_manifest_storage_key(*, scope: str, identifier: str) -> str:
normalized_scope = _normalize_segment(scope) or "general"
normalized_identifier = _normalize_segment(identifier) or "manifest"
return f"objects/manifest/{normalized_scope}/{normalized_identifier}.json"
def _normalize_segment(value: str) -> str:
cleaned = "".join(char if char.isalnum() or char in {"-", "_"} else "-" for char in value.strip().lower())
while "--" in cleaned:
cleaned = cleaned.replace("--", "-")
return cleaned.strip("-_")

3
app/models/entities.py

@ -147,7 +147,8 @@ class Backend(TimestampMixin, Base):
read_priority: Mapped[int] = mapped_column(Integer, nullable=False)
write_priority: Mapped[int] = mapped_column(Integer, nullable=False)
is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="1")
config_json: Mapped[str] = mapped_column(Text, nullable=False)
public_config_json: Mapped[str] = mapped_column(Text, nullable=False)
secret_config_json: Mapped[str] = mapped_column(Text, nullable=False)
capacity_hint_bytes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
last_health_status: Mapped[Optional[str]] = mapped_column(String, nullable=True)
last_health_checked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)

25
app/repositories/backend_repository.py

@ -1,9 +1,11 @@
from __future__ import annotations
import json
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.entities import Backend
from app.models.entities import Backend, PlacementPolicy
class BackendRepository:
@ -39,3 +41,24 @@ class BackendRepository:
await self.session.flush()
await self.session.refresh(backend)
return backend
async def delete(self, backend: Backend) -> None:
await self.session.delete(backend)
await self.session.flush()
async def is_referenced_by_policy(self, backend_id: str) -> bool:
result = await self.session.execute(select(PlacementPolicy))
for policy in result.scalars().all():
preferred = json.loads(policy.preferred_backend_ids_json or "[]")
excluded = json.loads(policy.excluded_backend_ids_json or "[]")
if backend_id in preferred or backend_id in excluded:
return True
return False
async def list_enabled_by_type(self, backend_type: str) -> list[Backend]:
result = await self.session.execute(
select(Backend)
.where(Backend.is_enabled.is_(True), Backend.type == backend_type)
.order_by(Backend.write_priority.desc(), Backend.read_priority.desc(), Backend.name.asc())
)
return list(result.scalars().all())

17
app/repositories/blob_replica_repository.py

@ -1,6 +1,6 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.entities import Backend
@ -50,6 +50,15 @@ class BlobReplicaRepository:
)
return list(result.all())
async def list_replicas_with_backends(self, blob_id: str) -> list[tuple[BlobReplica, Backend]]:
result = await self.session.execute(
select(BlobReplica, Backend)
.join(Backend, BlobReplica.backend_id == Backend.id)
.where(BlobReplica.blob_id == blob_id)
.order_by(Backend.read_priority.desc(), BlobReplica.created_at.asc())
)
return list(result.all())
async def list_by_blob(self, blob_id: str) -> list[BlobReplica]:
result = await self.session.execute(select(BlobReplica).where(BlobReplica.blob_id == blob_id))
return list(result.scalars().all())
@ -58,3 +67,9 @@ class BlobReplicaRepository:
await self.session.flush()
await self.session.refresh(replica)
return replica
async def count_by_backend(self, backend_id: str) -> int:
result = await self.session.execute(
select(func.count()).select_from(BlobReplica).where(BlobReplica.backend_id == backend_id)
)
return int(result.scalar_one())

12
app/schemas/backend.py

@ -1,6 +1,6 @@
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, Field
class BackendSummary(BaseModel):
@ -13,6 +13,8 @@ class BackendSummary(BaseModel):
is_enabled: bool
last_health_status: str | None
last_health_checked_at: datetime | None
config: dict | None = None
secret_fields: list[str] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
@ -30,6 +32,14 @@ class CreateBackendRequest(BaseModel):
config: dict
class UpdateBackendRequest(BaseModel):
name: str | None = None
stability_class: str | None = None
read_priority: int | None = None
write_priority: int | None = None
config: dict | None = None
class CreateBackendResponse(BaseModel):
backend: BackendSummary

33
app/schemas/file.py

@ -25,9 +25,42 @@ class PreviewArtifactSummary(BaseModel):
updated_at: datetime
class FileReplicaSummary(BaseModel):
id: str
blob_id: str
backend_id: str
backend_name: str
backend_type: str
backend_stability_class: str
backend_enabled: bool
storage_key: str
status: str
size_bytes: int
checksum: str | None
etag: str | None
last_verified_at: datetime | None
created_at: datetime
updated_at: datetime
class FilePlacementBackendSummary(BaseModel):
id: str
name: str
type: str
stability_class: str
read_priority: int
write_priority: int
is_enabled: bool
class FileDetailResponse(BaseModel):
file: FileSummary
preview_artifacts: list[PreviewArtifactSummary] = []
replicas: list[FileReplicaSummary] = []
placement_file_class: str | None = None
desired_backends: list[FilePlacementBackendSummary] = []
ready_replica_backend_ids: list[str] = []
missing_backend_ids: list[str] = []
class DeletedFileSummary(FileSummary):

1
app/schemas/policy.py

@ -35,6 +35,7 @@ class UpsertPlacementPolicyRequest(BaseModel):
class PlacementPolicyMutationResponse(BaseModel):
ok: bool = True
policy: PlacementPolicySummary
reconcile_enqueued_jobs: int = 0
class PlacementDecisionBackendSummary(BaseModel):

140
app/services/backend_service.py

@ -5,10 +5,21 @@ from datetime import UTC, datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, NotFoundError, ValidationError
from app.core.backend_config import (
SECRET_CONFIG_KEYS,
BackendConfigError,
merge_backend_config,
split_backend_config,
supported_backend_types,
validate_backend_config,
)
from app.core.exceptions import ConflictError, NotFoundError
from app.core.ids import new_id
from app.core.secret_codec import decrypt_secret_config, encrypt_secret_config
from app.models.entities import Backend
from app.repositories.backend_repository import BackendRepository
from app.repositories.blob_replica_repository import BlobReplicaRepository
from app.services.local_storage_service import DEFAULT_LOCAL_BACKEND_ID
from app.services.storage_service import StorageService
@ -17,12 +28,17 @@ class BackendNotFoundError(NotFoundError):
message = "Backend does not exist."
class BackendConfigError(ValidationError):
code = "backend_config_invalid"
message = "Backend configuration is invalid."
class BackendDeleteConflictError(ConflictError):
code = "backend_delete_conflict"
message = "Backend cannot be deleted."
SUPPORTED_BACKEND_TYPES = {"local", "s3"}
class BackendDisableConflictError(ConflictError):
code = "backend_disable_conflict"
message = "Backend cannot be disabled."
SUPPORTED_BACKEND_TYPES = supported_backend_types()
SUPPORTED_STABILITY_CLASSES = {"local", "stable", "opportunistic"}
@ -30,6 +46,7 @@ class BackendService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repository = BackendRepository(session)
self.blob_replica_repository = BlobReplicaRepository(session)
self.storage_service = StorageService(session)
async def list_backends(self) -> list[Backend]:
@ -55,7 +72,7 @@ class BackendService:
if await self.repository.get_by_name(cleaned_name) is not None:
raise ConflictError("A backend with this name already exists.")
normalized_config = self._validate_backend_config(backend_type, config)
public_config, secret_config = self._validate_backend_config(backend_type, config)
backend = Backend(
id=new_id("bkd"),
name=cleaned_name,
@ -64,7 +81,8 @@ class BackendService:
read_priority=read_priority,
write_priority=write_priority,
is_enabled=True,
config_json=json.dumps(normalized_config),
public_config_json=json.dumps(public_config),
secret_config_json=encrypt_secret_config(secret_config),
)
created = await self.repository.create(backend)
await self.session.commit()
@ -89,11 +107,84 @@ class BackendService:
async def disable_backend(self, backend_id: str) -> Backend:
backend = await self._get_backend(backend_id)
if backend.type == "local_directory":
enabled_local_backends = await self.repository.list_enabled_by_type("local_directory")
if backend.is_enabled and len(enabled_local_backends) <= 1:
raise BackendDisableConflictError("At least one enabled local_directory backend is required.")
backend.is_enabled = False
saved = await self.repository.save(backend)
await self.session.commit()
return saved
async def enable_backend(self, backend_id: str) -> Backend:
backend = await self._get_backend(backend_id)
backend.is_enabled = True
saved = await self.repository.save(backend)
await self.session.commit()
return saved
async def delete_backend(self, backend_id: str) -> None:
backend = await self._get_backend(backend_id)
if backend.id == DEFAULT_LOCAL_BACKEND_ID:
raise BackendDeleteConflictError("The default local backend cannot be deleted.")
if backend.is_enabled:
raise BackendDeleteConflictError("Disable the backend before deleting it.")
if await self.repository.is_referenced_by_policy(backend.id):
raise BackendDeleteConflictError("Backend is still referenced by a placement policy.")
replica_count = await self.blob_replica_repository.count_by_backend(backend.id)
if replica_count > 0:
raise BackendDeleteConflictError("Backend still has stored blob replicas.")
await self.repository.delete(backend)
await self.session.commit()
async def update_backend(
self,
backend_id: str,
*,
name: str | None = None,
stability_class: str | None = None,
read_priority: int | None = None,
write_priority: int | None = None,
config: dict | None = None,
) -> Backend:
backend = await self._get_backend(backend_id)
if name is not None:
cleaned_name = name.strip()
if not cleaned_name:
raise BackendConfigError("Backend name must not be empty.")
existing = await self.repository.get_by_name(cleaned_name)
if existing is not None and existing.id != backend.id:
raise ConflictError("A backend with this name already exists.")
backend.name = cleaned_name
if stability_class is not None:
if stability_class not in SUPPORTED_STABILITY_CLASSES:
raise BackendConfigError(f"Unsupported stability class: {stability_class}.")
backend.stability_class = stability_class
if read_priority is not None:
backend.read_priority = read_priority
if write_priority is not None:
backend.write_priority = write_priority
if config is not None:
public_config = json.loads(backend.public_config_json)
secret_config = decrypt_secret_config(backend.secret_config_json)
next_config = self._merge_config_update(
backend.type,
public_config=public_config,
secret_config=secret_config,
config_patch=config,
)
next_public_config, next_secret_config = self._validate_backend_config(backend.type, next_config)
backend.public_config_json = json.dumps(next_public_config)
backend.secret_config_json = encrypt_secret_config(next_secret_config)
saved = await self.repository.save(backend)
await self.session.commit()
return saved
async def _get_backend(self, backend_id: str) -> Backend:
backend = await self.repository.get_by_id(backend_id)
if backend is None:
@ -101,22 +192,21 @@ class BackendService:
return backend
@staticmethod
def _validate_backend_config(backend_type: str, config: dict) -> dict:
if backend_type == "local":
base_path = config.get("base_path")
if not isinstance(base_path, str) or not base_path.strip():
raise BackendConfigError("Local backend config requires a non-empty base_path.")
return {"base_path": base_path}
if backend_type == "s3":
bucket = config.get("bucket")
if not isinstance(bucket, str) or not bucket.strip():
raise BackendConfigError("S3 backend config requires a non-empty bucket.")
normalized = {"bucket": bucket}
for key in ("region", "endpoint_url", "access_key_id", "secret_access_key"):
value = config.get(key)
if value is not None:
normalized[key] = value
return normalized
def _validate_backend_config(backend_type: str, config: dict) -> tuple[dict, dict]:
return split_backend_config(backend_type, config)
raise BackendConfigError(f"Unsupported backend type: {backend_type}.")
@staticmethod
def _merge_config_update(
backend_type: str,
*,
public_config: dict,
secret_config: dict,
config_patch: dict,
) -> dict:
merged = merge_backend_config(public_config, secret_config)
normalized_patch = dict(config_patch)
for key in list(normalized_patch):
if key in SECRET_CONFIG_KEYS and not normalized_patch[key]:
normalized_patch.pop(key)
merged.update(normalized_patch)
return validate_backend_config(backend_type, merged)

14
app/services/file_service.py

@ -60,12 +60,20 @@ class FileService:
raise FileNotFoundError()
return file_entry
async def get_file_detail(self, file_id: str) -> tuple[FileEntry, list]:
async def get_file_detail(self, file_id: str) -> tuple[FileEntry, list, list, object | None]:
file_entry = await self.get_file(file_id)
if file_entry.current_version_id is None:
return file_entry, []
return file_entry, [], [], None
artifacts = await self.preview_artifact_repository.list_by_file_version(file_entry.current_version_id)
return file_entry, artifacts
blob = await self.file_repository.get_primary_blob_for_file(file_entry.id)
if blob is None:
return file_entry, artifacts, [], None
replicas = await self.blob_replica_repository.list_replicas_with_backends(blob.id)
decision = await self.storage_service.get_replication_targets_for_mime_type(
file_entry.mime_type,
size_bytes=blob.size_bytes,
)
return file_entry, artifacts, replicas, decision
async def list_deleted_files(self) -> list[FileEntry]:
return await self.file_repository.list_deleted()

16
app/services/local_storage_service.py

@ -9,6 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.adapters.storage.local import LocalStorageAdapter
from app.core.config import get_settings
from app.core.ids import new_id
from app.core.secret_codec import encrypt_secret_config
from app.core.storage_layout import build_blob_storage_key
from app.models.entities import Backend, Blob, BlobReplica
from app.repositories.backend_repository import BackendRepository
from app.repositories.blob_replica_repository import BlobReplicaRepository
@ -35,12 +37,13 @@ class LocalStorageService:
backend = Backend(
id=DEFAULT_LOCAL_BACKEND_ID,
name=DEFAULT_LOCAL_BACKEND_NAME,
type="local",
type="local_directory",
stability_class="local",
read_priority=100,
write_priority=100,
is_enabled=True,
config_json=json.dumps({"base_path": self.settings.local_storage_dir}),
public_config_json=json.dumps({"base_path": self.settings.local_storage_dir}),
secret_config_json=encrypt_secret_config({}),
)
await self.backend_repository.create(backend)
await self.session.commit()
@ -53,7 +56,7 @@ class LocalStorageService:
return existing
storage_key = self._build_storage_key(blob)
stored_path = await self.adapter.put_file(temp_path, storage_key)
await self.adapter.put_file(temp_path, storage_key)
replica = BlobReplica(
id=new_id("rep"),
blob_id=blob.id,
@ -66,7 +69,8 @@ class LocalStorageService:
created = await self.blob_replica_repository.create(replica)
await self.session.commit()
await anyio.to_thread.run_sync(lambda: Path(stored_path).exists())
stored_path = await self.adapter.resolve_path(storage_key)
await anyio.to_thread.run_sync(stored_path.exists)
return created
async def resolve_replica_path(self, replica: BlobReplica) -> Path:
@ -75,6 +79,4 @@ class LocalStorageService:
@staticmethod
def _build_storage_key(blob: Blob) -> str:
content_hash = blob.content_hash
prefix = content_hash[:2] if len(content_hash) >= 2 else "xx"
return f"blobs/{prefix}/{content_hash}"
return build_blob_storage_key(blob)

6
app/services/policy_service.py

@ -146,18 +146,18 @@ class PolicyService:
backends = await self.backend_repository.list_enabled()
eligible_backends = [backend for backend in backends if backend.id not in excluded_backend_ids]
local_backends = self._sort_backends(
[backend for backend in eligible_backends if backend.type == "local"],
[backend for backend in eligible_backends if backend.type == "local_directory"],
preferred_backend_ids,
)
stable_backends = [
backend
for backend in eligible_backends
if backend.type != "local" and backend.stability_class == "stable"
if backend.type != "local_directory" and backend.stability_class == "stable"
]
opportunistic_backends = [
backend
for backend in eligible_backends
if backend.type != "local" and backend.stability_class == "opportunistic"
if backend.type != "local_directory" and backend.stability_class == "opportunistic"
]
stable_backends = self._sort_backends(stable_backends, preferred_backend_ids)
opportunistic_backends = self._sort_backends(opportunistic_backends, preferred_backend_ids)

26
app/services/storage_service.py

@ -8,10 +8,12 @@ from pathlib import Path
import anyio
from sqlalchemy.ext.asyncio import AsyncSession
from app.adapters.storage.local import LocalStorageAdapter
from app.adapters.storage.s3 import S3StorageAdapter
from app.adapters.storage.registry import build_storage_adapter
from app.core.backend_config import merge_backend_config
from app.core.config import get_settings
from app.core.ids import new_id
from app.core.secret_codec import decrypt_secret_config
from app.core.storage_layout import build_blob_storage_key
from app.models.entities import Backend, Blob, BlobReplica, CacheEntry
from app.repositories.backend_repository import BackendRepository
from app.repositories.blob_replica_repository import BlobReplicaRepository
@ -53,10 +55,6 @@ class StorageService:
if existing_is_usable:
return existing
if backend.type == "local":
await adapter.put_file(source_path, storage_key)
etag = None
else:
result = await adapter.put_file(source_path, storage_key)
etag = result.get("etag")
@ -86,7 +84,7 @@ class StorageService:
return created
async def materialize_replica_to_local_cache(self, blob: Blob, replica: BlobReplica, backend: Backend) -> Path:
if backend.type == "local":
if backend.type == "local_directory":
adapter = self._build_adapter(backend)
return await adapter.resolve_path(replica.storage_key)
@ -144,15 +142,11 @@ class StorageService:
return result["status"], result.get("detail")
def _build_adapter(self, backend: Backend):
config = json.loads(backend.config_json)
if backend.type == "local":
return LocalStorageAdapter(config["base_path"])
if backend.type == "s3":
return S3StorageAdapter(config)
raise ValueError(f"Unsupported backend type: {backend.type}")
public_config = json.loads(backend.public_config_json)
secret_config = decrypt_secret_config(backend.secret_config_json)
config = merge_backend_config(public_config, secret_config)
return build_storage_adapter(backend.type, config)
@staticmethod
def _build_storage_key(blob: Blob) -> str:
content_hash = blob.content_hash
prefix = content_hash[:2] if len(content_hash) >= 2 else "xx"
return f"blobs/{prefix}/{content_hash}"
return build_blob_storage_key(blob)

2
app/web/dist/assets/app.css

File diff suppressed because one or more lines are too long

30
app/web/dist/assets/app.js

File diff suppressed because one or more lines are too long

10
deploy/iron/iron.env.example

@ -0,0 +1,10 @@
IRON_DATABASE_URL=sqlite+aiosqlite:////srv/iron/iron.db
IRON_BOOTSTRAP_USERNAME=admin
IRON_BOOTSTRAP_PASSWORD=replace-with-a-strong-password
IRON_SECRET_KEY=replace-with-a-long-random-secret
IRON_UPLOAD_TEMP_DIR=/srv/iron/runtime/temp
IRON_LOCAL_STORAGE_DIR=/srv/iron/runtime/local
IRON_CACHE_DIR=/srv/iron/runtime/cache
IRON_AUTH_SESSION_TTL_HOURS=168
IRON_JOB_POLL_INTERVAL_SECONDS=2
IRON_JOB_BATCH_SIZE=10

29
deploy/iron/start_iron.sh

@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
ENV_FILE="${IRON_ENV_FILE:-$ROOT_DIR/deploy/iron/iron.env}"
HOST="${IRON_HOST:-127.0.0.1}"
PORT="${IRON_PORT:-8000}"
cd "$ROOT_DIR"
if [[ ! -f ".venv/bin/python" ]]; then
echo "missing .venv/bin/python; install dependencies first" >&2
exit 1
fi
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
mkdir -p \
"${IRON_UPLOAD_TEMP_DIR:-./.iron-temp/uploads}" \
"${IRON_LOCAL_STORAGE_DIR:-./.iron-storage/local}" \
"${IRON_CACHE_DIR:-./.iron-cache}"
./.venv/bin/alembic upgrade head
exec ./.venv/bin/python -m uvicorn app.main:app --host "$HOST" --port "$PORT"

3
deploy/openlist/.env.example

@ -0,0 +1,3 @@
OPENLIST_BIND_HOST=127.0.0.1
OPENLIST_PORT=5244
OPENLIST_DATA_DIR=/srv/iron/openlist

9
deploy/openlist/docker-compose.yml

@ -0,0 +1,9 @@
services:
openlist:
image: openlistteam/openlist:latest
container_name: openlist
restart: unless-stopped
ports:
- "${OPENLIST_BIND_HOST:-127.0.0.1}:${OPENLIST_PORT:-5244}:5244"
volumes:
- "${OPENLIST_DATA_DIR:-/srv/iron/openlist}:/opt/openlist/data"

17
deploy/systemd/iron.service.example

@ -0,0 +1,17 @@
[Unit]
Description=Iron personal cloud drive gateway
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=iron
Group=iron
WorkingDirectory=/opt/iron
EnvironmentFile=/opt/iron/deploy/iron/iron.env
ExecStart=/opt/iron/deploy/iron/start_iron.sh
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target

1
docs/README.md

@ -6,6 +6,7 @@ This directory contains the stable project documentation for Iron.
- [Architecture](architecture.md): backend, frontend, data model, storage, and job design.
- [API](api.md): route map and API groups.
- [Development](development.md): setup, test commands, Playwright workflow, and documentation rules.
- [OpenList + Iron Local Deployment SOP](openlist-local-deployment-sop.md): step-by-step local setup for OpenList, Aliyun Drive, WebDAV, and Iron.
Root-level project docs:

24
docs/api.md

@ -49,8 +49,29 @@ Finalize endpoint:
- `GET /api/backends`
- `POST /api/backends`
- `PATCH /api/backends/{backend_id}`
- `POST /api/backends/{backend_id}/check`
- `POST /api/backends/{backend_id}/disable`
- `POST /api/backends/{backend_id}/enable`
- `DELETE /api/backends/{backend_id}`
Backend `type` values:
- `local_directory`
- `s3`
- `webdav`
Backend responses include public `config` values plus a `secret_fields` list for
configured credentials. Secret fields such as S3 credentials and WebDAV
passwords are never echoed back in clear text.
Backend updates preserve existing secret fields when they are omitted from the
request config, and replace them only when a new value is provided.
WebDAV configs should set `root_path` so Iron uses a dedicated folder tree
inside the remote endpoint, which is a good fit for OpenList-managed cloud
drives.
Backend deletion is guarded: the backend must be disabled first, must not be the
default local backend, must not still store blob replicas, and must not be
referenced by placement policies.
## Jobs
@ -67,6 +88,9 @@ Finalize endpoint:
- `PUT /api/policies/placement/{file_class}`
- `GET /api/policies/placement/preview`
Saving a placement policy also enqueues one full reconcile job so desired
replica placement converges without a separate manual step.
## Metadata Export And Recovery
- `GET /api/exports/metadata`

42
docs/architecture.md

@ -15,7 +15,7 @@ Repositories
|
SQLite metadata database
|
Storage adapters: local, S3
Storage adapters: local directory, S3, WebDAV
```
## Backend
@ -35,7 +35,7 @@ Important packages:
- `app/repositories/`: database access
- `app/models/`: SQLAlchemy entities
- `app/schemas/`: API schemas
- `app/adapters/storage/`: storage backends
- `app/adapters/storage/`: storage adapter protocol, registry, and backend implementations
## Frontend
@ -76,7 +76,8 @@ Design principles:
- physical content is represented as blobs and replicas
- user-facing deletion uses recycle-bin semantics
- jobs are durable and retryable
- backend-specific configuration stays behind adapter boundaries
- backend-specific configuration is validated before persistence and stays
behind adapter boundaries
## Upload And Read Path
@ -120,3 +121,38 @@ Current job types include:
- file reconcile
Every job should be safe to retry.
## Storage Backends
Supported backend types:
- `local_directory`: a filesystem directory used for default local persistence
and low-latency reads
- `s3`: S3 protocol storage, including AWS S3, MinIO, and compatible
object stores
- `webdav`: WebDAV storage, including OpenList-managed cloud drive directories
exposed through a dedicated `root_path`
Replica object keys are written into a structured application-owned layout such
as `objects/file/sha256/73/35/<hash>.blob`. This keeps remote storage organized
under a predictable object tree without trying to disguise replica content as
user-authored media files. During the current development stage, Iron treats
this layout as the only supported remote object format and does not preserve
compatibility with earlier experimental paths.
Reserved object-tree namespaces:
- `objects/file/...`: primary file blobs and replica payloads
- `objects/preview/<artifact_type>/...`: generated previews such as `inline_preview`, `thumbnail`, and `poster`
- `objects/export/metadata/YYYY/MM/DD/...`: metadata snapshot exports
- `objects/manifest/<scope>/...`: future manifest-style control files such as placement or repair manifests
Backend config is normalized through typed schemas before it is stored. Public
config and encrypted secret config are persisted separately. `IRON_SECRET_KEY`
derives the encryption key for stored backend credentials. API responses include
public config plus a list of configured secret fields so operators can identify
a backend without exposing access keys, client secrets, or refresh tokens. Update
requests can change public config and provide secret overrides; omitted secret
fields keep their previous encrypted values. Disabled backends can be deleted
only when they are no longer referenced by policies and no blob replicas remain
stored on them.

BIN
docs/assets/iron-demo.gif

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

25
docs/development.md

@ -42,7 +42,7 @@ Full suite:
Current expected result:
```text
66 passed
74 passed
```
Browser E2E:
@ -51,6 +51,19 @@ Browser E2E:
.venv/bin/python scripts/ui_playwright_smoke.py
```
Real deployment acceptance against a live local OpenList + Iron environment:
```bash
.venv/bin/python scripts/real_openlist_suite.py
```
Optional focused scenarios:
- `scripts/real_openlist_acceptance.py`: baseline upload, reconcile, download, direct WebDAV read
- `scripts/real_openlist_chaos_acceptance.py`: large file, range reads, forced remote recovery, OpenList flap, recycle-bin restore
- `scripts/real_openlist_multi_backend_acceptance.py`: multi-backend placement and enable/disable behavior
- `scripts/real_openlist_multi_backend_chaos.py`: degraded multi-backend recovery and replica healing
Pytest E2E entry:
```bash
@ -78,10 +91,17 @@ The E2E flow currently covers:
- authenticated file download
- upload image file
- authenticated inline image preview
- file storage replica inspector
- search
- uploads, recycle bin, storage, and jobs page render
- uploads, recycle bin, storage batch checks, backend edit dialog, policies save, and jobs page render
- browser `/api` 4xx/5xx failures during the flow
## Secrets
Backend credentials are encrypted before they are stored in SQLite. Local
development uses a default `IRON_SECRET_KEY`, but real deployments should set a
stable private value before creating S3 or WebDAV backends.
## Runtime Artifacts
Do not commit:
@ -92,6 +112,7 @@ Do not commit:
- `output/`
- `.iron-storage/`
- `.iron-temp/`
- `.iron-cache/`
- local databases
- Python caches

487
docs/openlist-local-deployment-sop.md

@ -0,0 +1,487 @@
# OpenList + Iron Local Deployment SOP
This SOP is for a single-machine deployment where:
- OpenList runs locally and mounts Aliyun Drive
- OpenList exposes WebDAV
- Iron runs locally and uses OpenList WebDAV as a remote backend
- Iron also keeps its built-in local backend for local reads and recovery
The result is:
```text
Aliyun Drive
-> OpenList mount
-> OpenList WebDAV
-> Iron WebDAV backend
-> Iron file namespace + policies + replicas
```
## 1. What This SOP Assumes
- You want a practical local deployment first, not a public Internet deployment.
- OpenList is used as the cloud-drive access layer.
- Iron is used as the file namespace, replica policy, and repair layer.
- Aliyun Drive data used by Iron should stay inside a dedicated folder, not the cloud-drive root.
This SOP uses:
- OpenList on `127.0.0.1:5244`
- Iron on `127.0.0.1:8000`
- Docker for OpenList
- local Python + `uv` for Iron
Repository deployment assets used in this SOP:
- `deploy/openlist/docker-compose.yml`
- `deploy/openlist/.env.example`
- `deploy/iron/iron.env.example`
- `deploy/iron/start_iron.sh`
- `deploy/systemd/iron.service.example`
OpenList also supports manual installation if you prefer not to use Docker.
## 2. Prerequisites
Prepare these first:
- Docker
- Python 3.11+
- Node.js 20+
- `uv`
- a local checkout of this repository
Recommended local ports:
- `5244` for OpenList
- `8000` for Iron
## 3. Recommended Directory Layout
Create a deployment working directory outside the Git repo for runtime data:
```bash
mkdir -p ~/iron-deploy/openlist
mkdir -p ~/iron-deploy/iron-data
mkdir -p ~/iron-deploy/iron-temp
mkdir -p ~/iron-deploy/iron-cache
```
Recommended meaning:
- `~/iron-deploy/openlist`: OpenList runtime data
- `~/iron-deploy/iron-data`: Iron local storage root
- `~/iron-deploy/iron-temp`: Iron upload temp files
- `~/iron-deploy/iron-cache`: Iron remote-read cache
## 4. Install And Start OpenList
This SOP uses Docker Compose because it is the shortest path to a repeatable local deployment.
Prepare the OpenList env file:
```bash
mkdir -p ~/iron-deploy/openlist
cp deploy/openlist/.env.example deploy/openlist/.env
```
Edit `deploy/openlist/.env` and set:
- `OPENLIST_BIND_HOST=127.0.0.1`
- `OPENLIST_PORT=5244`
- `OPENLIST_DATA_DIR=$HOME/iron-deploy/openlist`
Start OpenList:
```bash
docker compose -f deploy/openlist/docker-compose.yml --env-file deploy/openlist/.env up -d
```
Check logs:
```bash
docker compose -f deploy/openlist/docker-compose.yml logs -f
```
On first start, OpenList prints the initial admin password in the logs.
Open the admin UI:
```text
http://127.0.0.1:5244
```
Log in with the OpenList admin account shown on first boot, then immediately change it to a password you control.
## 5. Create An OpenList User For Iron
Do not let Iron use the OpenList admin account.
In OpenList:
1. go to `Users`
2. create a dedicated user, for example `iron`
3. give it a strong password
4. enable the permissions needed for WebDAV write access
For Iron write access through WebDAV, enable at least:
- `Webdav Read`
- `Webdav Manage`
- `Make dir or upload`
- `Delete`
- `Rename`
- `Move`
- `Copy`
Without these, Iron health checks may pass partially but upload or directory creation can fail.
## 6. Prepare A Dedicated Aliyun Drive Folder
Create a dedicated folder in Aliyun Drive for Iron, for example:
```text
IronGateway
```
Do not mount the whole cloud-drive root for Iron unless you explicitly want that.
OpenList supports mounting a specific Aliyun folder by `Root Folder ID`. The OpenList Aliyundrive Open docs describe using `root` for the drive root, or a folder `file_id` if you only want a specific folder exposed.
Recommended approach:
1. create `IronGateway` in Aliyun Drive
2. open that folder in the Aliyun Drive Web UI
3. copy the folder `file_id` from the URL
4. use that `file_id` as the OpenList `Root Folder ID`
That gives you one isolation layer in OpenList. Later, Iron will also use its own `root_path` inside that mount, giving you a second isolation layer.
## 7. Mount Aliyun Drive In OpenList
In OpenList:
1. go to `Storage`
2. click `Add Storage`
3. choose driver `AliYun Drive (Oauth2)` / `Aliyundrive Open`
4. set mount path to something stable, for example:
```text
/aliyun-iron
```
5. set `Root Folder ID` to the dedicated Aliyun folder `file_id`
6. fill the refresh token from the OpenList Aliyundrive Open authorization flow
7. save the storage
Important notes from the OpenList docs:
- normal users can leave `Client ID` and `Secret` blank and use the values provided by OpenList
- the refresh token used here must come from the Aliyundrive Open flow intended for OpenList
- the docs warn about request rate limiting and advise not to abuse the API
Operational note:
This means the current local-operator path does not require your own Aliyun developer app. That is based on the latest OpenList docs and may change if OpenList or Aliyun changes upstream behavior.
## 8. Verify OpenList WebDAV Before Touching Iron
OpenList exposes WebDAV at:
```text
http://127.0.0.1:5244/dav/
```
Before configuring Iron, verify all three things:
1. the `iron` OpenList user can log in
2. the mounted path `/aliyun-iron` is visible in the OpenList Web UI
3. the `iron` user has WebDAV write permissions
The practical target for Iron is:
```text
http://127.0.0.1:5244/dav
```
Iron will then use `root_path` to place its own dedicated subtree under the OpenList mount.
## 9. Install Iron
From the repository root:
```bash
UV_CACHE_DIR=.uv-cache uv venv .venv
UV_CACHE_DIR=.uv-cache uv pip install --python .venv/bin/python -e '.[dev]'
npm install
npm run build
```
Apply database migrations:
```bash
./.venv/bin/alembic upgrade head
```
## 10. Configure Iron Runtime Environment
Iron reads configuration from environment variables. For repeatable deployment, use the provided env template instead of one-off shell exports.
Create the runtime directories and env file:
```bash
mkdir -p ~/iron-deploy/runtime/temp
mkdir -p ~/iron-deploy/runtime/local
mkdir -p ~/iron-deploy/runtime/cache
cp deploy/iron/iron.env.example deploy/iron/iron.env
```
Edit `deploy/iron/iron.env` and set:
```bash
IRON_DATABASE_URL="sqlite+aiosqlite:////Users/your-user/iron-deploy/iron.db"
IRON_BOOTSTRAP_USERNAME="admin"
IRON_BOOTSTRAP_PASSWORD="replace-this-now"
IRON_SECRET_KEY="replace-with-a-long-random-stable-secret"
IRON_UPLOAD_TEMP_DIR="/Users/your-user/iron-deploy/runtime/temp"
IRON_LOCAL_STORAGE_DIR="/Users/your-user/iron-deploy/runtime/local"
IRON_CACHE_DIR="/Users/your-user/iron-deploy/runtime/cache"
IRON_JOB_POLL_INTERVAL_SECONDS="2"
IRON_JOB_BATCH_SIZE="10"
```
Important:
- `IRON_SECRET_KEY` must be stable after deployment begins, or stored backend passwords become unreadable.
- the bootstrap user is only auto-created when the database has no users yet
- changing `IRON_BOOTSTRAP_PASSWORD` later does not rotate the existing account password
## 11. Start Iron
Start the service:
```bash
./deploy/iron/start_iron.sh
```
Open:
```text
http://127.0.0.1:8000/app
```
Log in with:
- username: `IRON_BOOTSTRAP_USERNAME`
- password: `IRON_BOOTSTRAP_PASSWORD`
Health endpoints:
```text
http://127.0.0.1:8000/api/system/health
http://127.0.0.1:8000/api/system/ready
```
For a background service on Linux, adapt `deploy/systemd/iron.service.example` into `/etc/systemd/system/iron.service`.
## 12. Confirm The Built-In Local Backend
On first startup, Iron auto-creates the default local backend:
- name: `local-default`
- type: `Local Directory`
- policy class: `Local`
Keep this enabled.
This is the local durable copy that gives you:
- fast reads
- local recovery
- a safe fallback if OpenList or Aliyun is temporarily unavailable
## 13. Add OpenList As A WebDAV Backend In Iron
In Iron:
1. open `Storage`
2. click `Add Backend`
3. choose `OpenList / WebDAV`
4. use a stable backend name, for example `openlist-aliyun`
5. choose policy class `Stable`
6. use read and write priority appropriate for your setup, for example `80`
Use config JSON like this:
```json
{
"endpoint_url": "http://127.0.0.1:5244/dav",
"username": "iron",
"password": "your-openlist-iron-password",
"root_path": "aliyun-iron/iron-data",
"verify_ssl": false
}
```
Meaning:
- `endpoint_url`: OpenList WebDAV root
- `username` / `password`: the dedicated OpenList user created for Iron
- `root_path`: Iron's dedicated subtree
- `verify_ssl`: `false` only because this SOP uses local plain HTTP; use `true` when you switch to HTTPS
Recommended path layering:
```text
Aliyun folder: IronGateway
OpenList mount path: /aliyun-iron
Iron root_path: aliyun-iron/iron-data
```
This keeps Iron away from both:
- the Aliyun drive root
- the rest of the OpenList namespace
After saving, click `Run Check`.
Expected result:
- status becomes `Healthy`
- detail points to the configured WebDAV path
## 14. Configure Replica Policies
Open `Policies` in Iron and configure at least these defaults:
Recommended first production baseline:
- `Require local replica = on`
- `Stable replicas = 1`
- `Opportunistic replicas = 0`
Interpretation:
- every file keeps one local copy
- every file also gets one stable remote copy on OpenList/Aliyun
For each file class (`document`, `image`, `video`, `other`):
1. save the policy
2. confirm the OpenList backend is not excluded
3. keep the local backend available
Saving a policy automatically enqueues a full reconcile job.
## 15. First End-To-End Acceptance Test
Use this exact sequence:
1. in Iron, upload a small text file
2. open the file detail drawer and check the `Storage` tab
3. confirm the desired backend list includes:
- local backend
- OpenList WebDAV backend
4. wait for the replica to become ready, or go to `Jobs` and run pending jobs
5. in OpenList, browse the mount path and confirm the file exists under:
```text
/aliyun-iron/iron-data/...
```
6. in Aliyun Drive, verify the file appears under the dedicated `IronGateway` folder
Expected outcome:
- Iron can upload locally
- Iron can replicate to OpenList WebDAV
- OpenList writes into the dedicated Aliyun subtree
After the first manual upload check, run the real acceptance suite:
```bash
./.venv/bin/python scripts/real_openlist_suite.py
```
Useful focused runs:
```bash
./.venv/bin/python scripts/real_openlist_acceptance.py
./.venv/bin/python scripts/real_openlist_chaos_acceptance.py
./.venv/bin/python scripts/real_openlist_multi_backend_acceptance.py
./.venv/bin/python scripts/real_openlist_multi_backend_chaos.py
```
The suite covers:
- baseline upload, reconcile, download, and direct WebDAV read
- large-file range reads
- forced remote recovery after local file deletion
- OpenList stop/start recovery
- multi-backend placement, avoidance, re-enable, and replica healing
- recycle-bin delete and restore with replica validation
Current remote object layout note:
- Iron stores backend replicas under a structured application object tree such as
`objects/file/sha256/73/35/<hash>.blob`
- this is intentional application storage layout, not an attempt to disguise replicas as user-authored media files
- keep Iron inside a dedicated OpenList mount subtree rather than mixing it with personal root folders
- because the project is still in active development, this layout is treated as the current source of truth rather than a backward-compatible migration target
- reserved subtrees include `objects/preview/...`, `objects/export/metadata/...`, and `objects/manifest/...` for future generated artifacts and control files
## 16. What To Check If It Fails
### OpenList backend check fails in Iron
Check:
- OpenList is reachable on `127.0.0.1:5244`
- WebDAV URL is exactly `/dav`
- the OpenList `iron` user password is correct
- WebDAV permissions include write-related permissions
- `root_path` starts with the OpenList mount path, for example `aliyun-iron/iron-data`
### Iron uploads succeed locally but remote replica never appears
Check:
- `Jobs` page for failed reconcile or replication jobs
- OpenList user permissions
- placement policy excludes or preferred backend settings
- whether the WebDAV backend is enabled
### Files land in the wrong Aliyun location
Check:
- OpenList `Root Folder ID`
- OpenList mount path
- Iron `root_path`
Usually this is a path-scoping mistake, not a storage corruption issue.
## 17. Recommended Ongoing Operations
For a stable local deployment:
1. keep OpenList and Iron on localhost unless you add a reverse proxy and auth hardening
2. keep `IRON_SECRET_KEY` unchanged
3. back up:
- `iron.db`
- `~/iron-deploy/openlist`
- `~/iron-deploy/runtime/local`
- `~/iron-deploy/runtime/cache`
4. run backend health checks from the Iron `Storage` page after OpenList upgrades
5. review the Iron `Jobs` page when replicas lag or a cloud drive is rate-limited
## 18. Source References
- OpenList install guide: [docs.openlist.team/guide/install](https://docs.openlist.team/guide/install/)
- OpenList manual install guide: [docs.openlist.team/guide/install/manual](https://docs.openlist.team/guide/install/manual)
- OpenList WebDAV guide: [docs.openlist.team/guide/webdav](https://docs.openlist.team/guide/webdav)
- OpenList Aliyundrive Open guide: [doc.openlist.team/guide/drivers/aliyundrive_open](https://doc.openlist.team/guide/drivers/aliyundrive_open)
- Iron quick start and env vars: [README](../README.md)
- Iron API backend types and WebDAV notes: [API](api.md)
- Iron storage architecture notes: [Architecture](architecture.md)

11
docs/product.md

@ -14,8 +14,15 @@ Implemented:
- file browser with folders, search, sort, pagination, and an inspector panel
- create folder, upload, rename, move, delete, restore, download, and image preview
- uploads, recycle bin, storage, and jobs pages
- local backend persistence
- backend config edit, enable, disable, and health check actions
- guarded backend delete workflow and batch health checks
- placement policy editing and decision preview
- file replica visibility and manual reconcile from the inspector
- automatic full reconcile enqueue after policy changes
- local directory backend persistence
- S3 runtime replication and remote fallback reads
- OpenList/WebDAV backend configuration, dedicated remote root path, upload,
download, and health-check path
- placement policies and reconcile jobs
- metadata export, validate, integrity, restore-plan, and guarded import
- Python Playwright E2E coverage for the core browser flow
@ -61,7 +68,7 @@ Near-term:
Later:
- Aliyun and Baidu adapters
- Baidu adapter
- richer cache policy
- share links
- desktop and mobile clients

5
frontend/src/app/router.tsx

@ -11,6 +11,7 @@ import { FilesPage } from "../routes/FilesPage";
import { UploadsPage } from "../routes/UploadsPage";
import { RecycleBinPage } from "../routes/RecycleBinPage";
import { StoragePage } from "../routes/StoragePage";
import { PoliciesPage } from "../routes/PoliciesPage";
import { JobsPage } from "../routes/JobsPage";
function AppShell() {
@ -58,6 +59,10 @@ export const router = createBrowserRouter([
path: "storage",
element: <StoragePage />,
},
{
path: "policies",
element: <PoliciesPage />,
},
{
path: "jobs",
element: <JobsPage />,

4
frontend/src/components/layout/AppLayout.tsx

@ -14,6 +14,7 @@ const navItems = [
{ to: "/app/uploads", label: "Uploads" },
{ to: "/app/recycle-bin", label: "Recycle Bin" },
{ to: "/app/storage", label: "Storage" },
{ to: "/app/policies", label: "Policies" },
{ to: "/app/jobs", label: "Jobs" },
];
@ -99,6 +100,9 @@ function getPageTitle(pathname: string) {
if (pathname.startsWith("/app/storage")) {
return "Storage";
}
if (pathname.startsWith("/app/policies")) {
return "Policies";
}
if (pathname.startsWith("/app/jobs")) {
return "Jobs";
}

129
frontend/src/lib/api.ts

@ -59,9 +59,42 @@ export type PreviewArtifactSummary = {
updated_at: string;
};
export type FileReplicaSummary = {
id: string;
blob_id: string;
backend_id: string;
backend_name: string;
backend_type: string;
backend_stability_class: string;
backend_enabled: boolean;
storage_key: string;
status: string;
size_bytes: number;
checksum: string | null;
etag: string | null;
last_verified_at: string | null;
created_at: string;
updated_at: string;
};
export type FilePlacementBackendSummary = {
id: string;
name: string;
type: string;
stability_class: string;
read_priority: number;
write_priority: number;
is_enabled: boolean;
};
export type FileDetailResponse = {
file: FileSummary;
preview_artifacts: PreviewArtifactSummary[];
replicas: FileReplicaSummary[];
placement_file_class: string | null;
desired_backends: FilePlacementBackendSummary[];
ready_replica_backend_ids: string[];
missing_backend_ids: string[];
};
export type DeletedFileSummary = FileSummary & {
@ -82,6 +115,8 @@ export type BackendSummary = {
is_enabled: boolean;
last_health_status: string | null;
last_health_checked_at: string | null;
config: Record<string, unknown> | null;
secret_fields: string[];
created_at: string;
updated_at: string;
};
@ -90,6 +125,49 @@ export type BackendListResponse = {
items: BackendSummary[];
};
export type PlacementPolicySummary = {
id: string;
file_class: "document" | "image" | "video" | "other";
require_local: boolean;
stable_replica_count: number;
opportunistic_replica_count: number;
preferred_backend_ids: string[];
excluded_backend_ids: string[];
max_non_local_size_bytes: number | null;
created_at: string;
updated_at: string;
};
export type PlacementPolicyPayload = {
require_local: boolean;
stable_replica_count: number;
opportunistic_replica_count: number;
preferred_backend_ids: string[];
excluded_backend_ids: string[];
max_non_local_size_bytes: number | null;
};
export type PlacementPolicyMutationResponse = {
policy: PlacementPolicySummary;
reconcile_enqueued_jobs: number;
};
export type PlacementDecisionResponse = {
file_class: string;
mime_type: string | null;
size_bytes: number | null;
non_local_allowed: boolean;
policy: PlacementPolicySummary;
selected_backends: Array<{
id: string;
name: string;
type: string;
stability_class: string;
read_priority: number;
write_priority: number;
}>;
};
export type BackendCheckResponse = {
backend_id: string;
status: string;
@ -97,6 +175,23 @@ export type BackendCheckResponse = {
detail: string | null;
};
export type CreateBackendPayload = {
name: string;
type: "local_directory" | "s3" | "webdav";
stability_class: "local" | "stable" | "opportunistic";
read_priority: number;
write_priority: number;
config: Record<string, unknown>;
};
export type UpdateBackendPayload = {
name?: string;
stability_class?: "local" | "stable" | "opportunistic";
read_priority?: number;
write_priority?: number;
config?: Record<string, unknown>;
};
export type JobSummary = {
id: string;
kind: string;
@ -226,6 +321,10 @@ export const api = {
fileDetail: (fileId: string) => request<FileDetailResponse>(`/api/files/${fileId}`),
fileBlob: (fileId: string, variant: "download" | "preview" | "stream" = "download") =>
requestBlob(`/api/files/${fileId}/${variant}`),
reconcileFile: (fileId: string) =>
request<{ file_id: string; enqueued_jobs: number; reconciliation_job_kind: string }>(`/api/files/${fileId}/reconcile`, {
method: "POST",
}),
renameFile: (fileId: string, name: string) =>
request(`/api/files/${fileId}/rename`, {
method: "POST",
@ -246,10 +345,40 @@ export const api = {
}),
listRecycleBin: () => request<RecycleBinResponse>("/api/files/recycle-bin"),
listBackends: () => request<BackendListResponse>("/api/backends"),
createBackend: (payload: CreateBackendPayload) =>
request<{ backend: BackendSummary }>("/api/backends", {
method: "POST",
body: JSON.stringify(payload),
}),
updateBackend: (backendId: string, payload: UpdateBackendPayload) =>
request<{ backend: BackendSummary }>(`/api/backends/${backendId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}),
checkBackend: (backendId: string) =>
request<BackendCheckResponse>(`/api/backends/${backendId}/check`, { method: "POST" }),
disableBackend: (backendId: string) =>
request(`/api/backends/${backendId}/disable`, { method: "POST" }),
enableBackend: (backendId: string) =>
request(`/api/backends/${backendId}/enable`, { method: "POST" }),
deleteBackend: (backendId: string) =>
request(`/api/backends/${backendId}`, { method: "DELETE" }),
listPlacementPolicies: () => request<{ items: PlacementPolicySummary[] }>("/api/policies/placement"),
updatePlacementPolicy: (fileClass: string, payload: PlacementPolicyPayload) =>
request<PlacementPolicyMutationResponse>(`/api/policies/placement/${fileClass}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
previewPlacementDecision: (mimeType: string, sizeBytes: number | null) => {
const params = new URLSearchParams();
if (mimeType.trim()) {
params.set("mime_type", mimeType.trim());
}
if (sizeBytes !== null) {
params.set("size_bytes", String(sizeBytes));
}
return request<PlacementDecisionResponse>(`/api/policies/placement/preview?${params.toString()}`);
},
listJobs: () => request<JobListResponse>("/api/jobs"),
retryJob: (jobId: string) =>
request(`/api/jobs/${jobId}/retry`, { method: "POST" }),

13
frontend/src/lib/format.ts

@ -37,3 +37,16 @@ export function titleCase(value: string) {
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
}
export function formatBackendType(value: string) {
switch (value) {
case "local_directory":
return "Local Directory";
case "s3":
return "S3";
case "webdav":
return "OpenList / WebDAV";
default:
return titleCase(value);
}
}

120
frontend/src/routes/FilesPage.tsx

@ -5,7 +5,7 @@ import { Dialog } from "../components/Dialog";
import { Menu } from "../components/Menu";
import { DirectoryTree } from "../components/DirectoryTree";
import { api, type DirectoryItem } from "../lib/api";
import { formatBytes, formatDate, titleCase } from "../lib/format";
import { formatBackendType, formatBytes, formatDate, titleCase } from "../lib/format";
import { uploadFileToDirectory } from "../lib/uploads";
import { useToast } from "../features/toast/ToastProvider";
@ -200,6 +200,25 @@ export function FilesPage() {
},
});
const reconcileMutation = useMutation({
mutationFn: async () => {
if (!selectedItem || selectedItem.kind !== "file") {
throw new Error("Select a file to reconcile.");
}
return api.reconcileFile(selectedItem.id);
},
onSuccess: async (result) => {
pushToast(`Reconcile queued ${result.enqueued_jobs} job${result.enqueued_jobs === 1 ? "" : "s"}.`, "success");
if (selectedItem?.kind === "file") {
await queryClient.invalidateQueries({ queryKey: ["files", selectedItem.id, "detail"] });
}
await queryClient.invalidateQueries({ queryKey: ["jobs"] });
},
onError: (error) => {
pushToast(error instanceof Error ? error.message : "Reconcile failed.", "error");
},
});
async function handleUploadSelection(event: ChangeEvent<HTMLInputElement>) {
const fileList = event.target.files;
if (!fileList?.length) {
@ -783,12 +802,103 @@ export function FilesPage() {
<h3>No recent activity</h3>
<p>Upload, rename, move, and preview events will surface here as the product grows.</p>
</div>
) : (
selectedItem.kind === "file" ? (
<div className="detail-tab-panel">
<div className="inspector-section">
<span className="eyebrow">Placement</span>
<div className="inspector-card storage-summary-card">
<div>
<span>Class</span>
<strong>{titleCase(detailQuery.data?.placement_file_class ?? "unknown")}</strong>
</div>
<div>
<span>Desired</span>
<strong>{detailQuery.data?.desired_backends.length ?? 0}</strong>
</div>
<div>
<span>Ready replicas</span>
<strong>{detailQuery.data?.ready_replica_backend_ids.length ?? 0}</strong>
</div>
</div>
{detailQuery.data?.missing_backend_ids.length ? (
<div className="inspector-card replica-warning-card">
<strong>Replica gap detected</strong>
<p className="item-muted">
Missing {detailQuery.data.missing_backend_ids.length} desired backend
{detailQuery.data.missing_backend_ids.length === 1 ? "" : "s"}.
</p>
</div>
) : null}
<button
className="ghost-button"
type="button"
disabled={reconcileMutation.isPending}
onClick={() => reconcileMutation.mutate()}
>
Reconcile File
</button>
</div>
<div className="inspector-section">
<span className="eyebrow">Desired Backends</span>
<div className="inspector-card replica-list">
{detailQuery.data?.desired_backends.length ? (
detailQuery.data.desired_backends.map((backend) => (
<div key={backend.id} className="replica-row">
<div>
<strong>{backend.name}</strong>
<span className="item-muted">
{formatBackendType(backend.type)} · {titleCase(backend.stability_class)}
</span>
</div>
<span
className={
detailQuery.data.missing_backend_ids.includes(backend.id)
? "status-chip"
: "status-chip status-chip-success"
}
>
{detailQuery.data.missing_backend_ids.includes(backend.id) ? "Missing" : "Ready"}
</span>
</div>
))
) : (
<p className="item-muted">No placement targets selected.</p>
)}
</div>
</div>
<div className="inspector-section">
<span className="eyebrow">Replicas</span>
<div className="inspector-card replica-list">
{detailQuery.data?.replicas.length ? (
detailQuery.data.replicas.map((replica) => (
<div key={replica.id} className="replica-row">
<div>
<strong>{replica.backend_name}</strong>
<span className="item-muted">
{formatBytes(replica.size_bytes)} · {formatReplicaVerification(replica.last_verified_at)}
</span>
</div>
<span className={replica.status === "ready" ? "status-chip status-chip-success" : "status-chip"}>
{titleCase(replica.status)}
</span>
</div>
))
) : (
<p className="item-muted">No recorded replicas for this file yet.</p>
)}
</div>
</div>
</div>
) : (
<div className="detail-empty detail-tab-panel">
<span className="eyebrow">Storage</span>
<h3>Storage summary</h3>
<p>This item is available through the gateway. Replica and backend details can expand here later.</p>
<h3>Folder storage</h3>
<p>Replica placement is tracked for files.</p>
</div>
)
)}
</>
)}
@ -1015,3 +1125,7 @@ function dialogDescription(mode: DialogMode, selectedItem: DirectoryItem | null)
}
return `This will move ${selectedItem?.name ?? "this file"} into the recycle bin.`;
}
function formatReplicaVerification(value: string | null) {
return value ? `verified ${formatDate(value)}` : "Not verified yet";
}

290
frontend/src/routes/PoliciesPage.tsx

@ -0,0 +1,290 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type PlacementPolicyPayload, type PlacementPolicySummary } from "../lib/api";
import { useToast } from "../features/toast/ToastProvider";
import { formatBackendType, formatBytes, titleCase } from "../lib/format";
const FILE_CLASSES: PlacementPolicySummary["file_class"][] = ["document", "image", "video", "other"];
const MIME_PRESETS: Record<PlacementPolicySummary["file_class"], string> = {
document: "application/pdf",
image: "image/png",
video: "video/mp4",
other: "application/octet-stream",
};
export function PoliciesPage() {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [selectedClass, setSelectedClass] = useState<PlacementPolicySummary["file_class"]>("document");
const [requireLocal, setRequireLocal] = useState(true);
const [stableCount, setStableCount] = useState(1);
const [opportunisticCount, setOpportunisticCount] = useState(0);
const [preferredBackendIds, setPreferredBackendIds] = useState<string[]>([]);
const [excludedBackendIds, setExcludedBackendIds] = useState<string[]>([]);
const [maxNonLocalSize, setMaxNonLocalSize] = useState("");
const [mimeType, setMimeType] = useState(MIME_PRESETS.document);
const [previewSize, setPreviewSize] = useState("1048576");
const [formError, setFormError] = useState("");
const policiesQuery = useQuery({
queryKey: ["placement-policies"],
queryFn: api.listPlacementPolicies,
});
const backendQuery = useQuery({
queryKey: ["backends"],
queryFn: api.listBackends,
});
const policies = policiesQuery.data?.items ?? [];
const backends = backendQuery.data?.items ?? [];
const selectedPolicy = policies.find((policy) => policy.file_class === selectedClass);
useEffect(() => {
if (!selectedPolicy) {
return;
}
setRequireLocal(selectedPolicy.require_local);
setStableCount(selectedPolicy.stable_replica_count);
setOpportunisticCount(selectedPolicy.opportunistic_replica_count);
setPreferredBackendIds(selectedPolicy.preferred_backend_ids);
setExcludedBackendIds(selectedPolicy.excluded_backend_ids);
setMaxNonLocalSize(
selectedPolicy.max_non_local_size_bytes === null ? "" : String(selectedPolicy.max_non_local_size_bytes),
);
}, [selectedPolicy?.id, selectedPolicy?.updated_at]);
useEffect(() => {
setMimeType(MIME_PRESETS[selectedClass]);
}, [selectedClass]);
const previewSizeBytes = parseOptionalInt(previewSize);
const previewQuery = useQuery({
queryKey: ["placement-preview", mimeType, previewSizeBytes],
queryFn: () => api.previewPlacementDecision(mimeType, previewSizeBytes),
});
const updateMutation = useMutation({
mutationFn: (payload: PlacementPolicyPayload) => api.updatePlacementPolicy(selectedClass, payload),
onSuccess: async (result) => {
setFormError("");
pushToast(
`Policy saved. Full reconcile queued ${result.reconcile_enqueued_jobs} job${result.reconcile_enqueued_jobs === 1 ? "" : "s"}.`,
"success",
);
await queryClient.invalidateQueries({ queryKey: ["placement-policies"] });
await queryClient.invalidateQueries({ queryKey: ["placement-preview"] });
await queryClient.invalidateQueries({ queryKey: ["jobs"] });
},
onError: (error) => {
setFormError(error instanceof Error ? error.message : "Policy update failed.");
pushToast(error instanceof Error ? error.message : "Policy update failed.", "error");
},
});
const localBackendCount = backends.filter((backend) => backend.type === "local_directory" && backend.is_enabled).length;
const stableBackendCount = backends.filter((backend) => backend.stability_class === "stable" && backend.is_enabled).length;
const opportunisticBackendCount = backends.filter(
(backend) => backend.stability_class === "opportunistic" && backend.is_enabled,
).length;
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const maxSize = parseOptionalInt(maxNonLocalSize);
if (maxNonLocalSize.trim() && maxSize === null) {
setFormError("Max non-local size must be a whole number.");
return;
}
updateMutation.mutate({
require_local: requireLocal,
stable_replica_count: Math.max(0, stableCount),
opportunistic_replica_count: Math.max(0, opportunisticCount),
preferred_backend_ids: preferredBackendIds,
excluded_backend_ids: excludedBackendIds,
max_non_local_size_bytes: maxSize,
});
}
function toggleBackend(listName: "preferred" | "excluded", backendId: string) {
if (listName === "preferred") {
setPreferredBackendIds((current) => toggleId(current, backendId));
setExcludedBackendIds((current) => current.filter((id) => id !== backendId));
return;
}
setExcludedBackendIds((current) => toggleId(current, backendId));
setPreferredBackendIds((current) => current.filter((id) => id !== backendId));
}
return (
<div className="compact-page-shell compact-page-shell-wide">
<div className="dashboard-page-grid">
<section className="dashboard-main-column">
<div className="panel-toolbar storage-toolbar">
<div className="section-heading">
<span className="eyebrow">Placement</span>
<h3>Replica policies</h3>
</div>
</div>
<div className="summary-strip summary-strip-four-up">
<article className="summary-card">
<span className="metric-label">Policies</span>
<strong>{policies.length}</strong>
</article>
<article className="summary-card">
<span className="metric-label">Local</span>
<strong>{localBackendCount}</strong>
</article>
<article className="summary-card">
<span className="metric-label">Stable</span>
<strong>{stableBackendCount}</strong>
</article>
<article className="summary-card">
<span className="metric-label">Opportunistic</span>
<strong>{opportunisticBackendCount}</strong>
</article>
</div>
<div className="policy-tabs" role="tablist" aria-label="File classes">
{FILE_CLASSES.map((fileClass) => (
<button
key={fileClass}
className={selectedClass === fileClass ? "policy-tab active" : "policy-tab"}
type="button"
onClick={() => setSelectedClass(fileClass)}
>
{titleCase(fileClass)}
</button>
))}
</div>
<form className="panel policy-editor" onSubmit={handleSubmit}>
<div className="policy-editor-grid">
<label className="checkbox-field">
<input
type="checkbox"
checked={requireLocal}
onChange={(event) => setRequireLocal(event.target.checked)}
/>
<span>Require local replica</span>
</label>
<label>
<span>Stable replicas</span>
<input
type="number"
min={0}
value={stableCount}
onChange={(event) => setStableCount(Number(event.target.value))}
/>
</label>
<label>
<span>Opportunistic replicas</span>
<input
type="number"
min={0}
value={opportunisticCount}
onChange={(event) => setOpportunisticCount(Number(event.target.value))}
/>
</label>
<label>
<span>Max non-local bytes</span>
<input
value={maxNonLocalSize}
onChange={(event) => setMaxNonLocalSize(event.target.value)}
placeholder="No limit"
/>
</label>
</div>
<div className="backend-policy-list">
{backends.map((backend) => (
<div key={backend.id} className="backend-policy-row">
<div>
<strong>{backend.name}</strong>
<span className="item-muted">
{formatBackendType(backend.type)} · {titleCase(backend.stability_class)}
</span>
</div>
<div className="backend-policy-actions">
<label className="checkbox-field compact-checkbox">
<input
type="checkbox"
checked={preferredBackendIds.includes(backend.id)}
onChange={() => toggleBackend("preferred", backend.id)}
/>
<span>Prefer</span>
</label>
<label className="checkbox-field compact-checkbox">
<input
type="checkbox"
checked={excludedBackendIds.includes(backend.id)}
onChange={() => toggleBackend("excluded", backend.id)}
/>
<span>Exclude</span>
</label>
</div>
</div>
))}
</div>
{formError ? <p className="error-text">{formError}</p> : null}
<div className="dialog-actions">
<button type="submit" disabled={updateMutation.isPending}>
Save Policy
</button>
</div>
</form>
</section>
<aside className="dashboard-side-column">
<section className="panel dashboard-side-panel">
<div className="section-heading">
<span className="eyebrow">Preview</span>
<h3>Placement decision</h3>
</div>
<div className="dialog-form">
<label>
<span>Media type</span>
<input value={mimeType} onChange={(event) => setMimeType(event.target.value)} />
</label>
<label>
<span>Object size</span>
<input value={previewSize} onChange={(event) => setPreviewSize(event.target.value)} />
</label>
</div>
<div className="decision-list">
<span className="status-chip">
{previewQuery.data?.non_local_allowed === false ? "Local only" : "Non-local allowed"}
</span>
<span className="item-muted">
{previewSizeBytes === null ? "No size input" : formatBytes(previewSizeBytes)}
</span>
{(previewQuery.data?.selected_backends ?? []).map((backend) => (
<div key={backend.id} className="decision-backend">
<strong>{backend.name}</strong>
<span>{formatBackendType(backend.type)}</span>
</div>
))}
</div>
</section>
</aside>
</div>
</div>
);
}
function toggleId(values: string[], id: string) {
return values.includes(id) ? values.filter((value) => value !== id) : [...values, id];
}
function parseOptionalInt(value: string) {
const cleaned = value.trim();
if (!cleaned) {
return null;
}
const parsed = Number(cleaned);
if (!Number.isInteger(parsed) || parsed < 0) {
return null;
}
return parsed;
}

441
frontend/src/routes/StoragePage.tsx

@ -1,36 +1,241 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FormEvent, useState } from "react";
import { Dialog } from "../components/Dialog";
import { useToast } from "../features/toast/ToastProvider";
import { api } from "../lib/api";
import { formatDate, titleCase } from "../lib/format";
import type { BackendSummary } from "../lib/api";
import { formatBackendType, formatDate, titleCase } from "../lib/format";
type BackendType = "local_directory" | "s3" | "webdav";
type StabilityClass = "local" | "stable" | "opportunistic";
const CONFIG_TEMPLATES: Record<BackendType, string> = {
local_directory: JSON.stringify({ base_path: "./.iron-storage/archive" }, null, 2),
s3: JSON.stringify(
{
bucket: "iron",
region: "us-east-1",
endpoint_url: "http://127.0.0.1:9000",
access_key_id: "",
secret_access_key: "",
prefix: "iron",
force_path_style: true,
},
null,
2,
),
webdav: JSON.stringify(
{
endpoint_url: "http://127.0.0.1:5244/dav",
username: "iron",
password: "",
root_path: "iron",
verify_ssl: true,
},
null,
2,
),
};
export function StoragePage() {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [backendType, setBackendType] = useState<BackendType>("s3");
const [stabilityClass, setStabilityClass] = useState<StabilityClass>("stable");
const [name, setName] = useState("");
const [readPriority, setReadPriority] = useState(80);
const [writePriority, setWritePriority] = useState(80);
const [configText, setConfigText] = useState(CONFIG_TEMPLATES.s3);
const [formError, setFormError] = useState("");
const [editingBackend, setEditingBackend] = useState<BackendSummary | null>(null);
const [editName, setEditName] = useState("");
const [editStabilityClass, setEditStabilityClass] = useState<StabilityClass>("stable");
const [editReadPriority, setEditReadPriority] = useState(80);
const [editWritePriority, setEditWritePriority] = useState(80);
const [editConfigText, setEditConfigText] = useState("{}");
const [editSecretText, setEditSecretText] = useState("{}");
const [editError, setEditError] = useState("");
const [deleteTarget, setDeleteTarget] = useState<BackendSummary | null>(null);
const backendQuery = useQuery({
queryKey: ["backends"],
queryFn: api.listBackends,
});
function handleTypeChange(nextType: BackendType) {
setBackendType(nextType);
setConfigText(CONFIG_TEMPLATES[nextType]);
setStabilityClass(nextType === "local_directory" ? "local" : "stable");
if (!name.trim()) {
setName(
nextType === "local_directory"
? "local-archive"
: nextType === "s3"
? "s3-main"
: "openlist-main",
);
}
}
const createMutation = useMutation({
mutationFn: api.createBackend,
onSuccess: async () => {
setIsCreateOpen(false);
setFormError("");
await queryClient.invalidateQueries({ queryKey: ["backends"] });
},
onError: (error) => {
setFormError(error instanceof Error ? error.message : "Backend creation failed.");
},
});
const checkMutation = useMutation({
mutationFn: (backendId: string) => api.checkBackend(backendId),
onSuccess: async () => {
pushToast("Backend check finished.", "success");
await queryClient.invalidateQueries({ queryKey: ["backends"] });
},
onError: (error) => {
pushToast(error instanceof Error ? error.message : "Backend check failed.", "error");
},
});
const disableMutation = useMutation({
mutationFn: (backendId: string) => api.disableBackend(backendId),
onSuccess: async () => {
pushToast("Backend disabled.", "success");
await queryClient.invalidateQueries({ queryKey: ["backends"] });
},
onError: (error) => {
pushToast(error instanceof Error ? error.message : "Disable failed.", "error");
},
});
const enableMutation = useMutation({
mutationFn: (backendId: string) => api.enableBackend(backendId),
onSuccess: async () => {
pushToast("Backend enabled.", "success");
await queryClient.invalidateQueries({ queryKey: ["backends"] });
},
onError: (error) => {
pushToast(error instanceof Error ? error.message : "Enable failed.", "error");
},
});
const runAllChecksMutation = useMutation({
mutationFn: api.enqueueHealthChecks,
onSuccess: async (result) => {
pushToast(`Queued ${result.enqueued_jobs} backend health check job${result.enqueued_jobs === 1 ? "" : "s"}.`, "success");
await queryClient.invalidateQueries({ queryKey: ["jobs"] });
},
onError: (error) => {
pushToast(error instanceof Error ? error.message : "Could not queue health checks.", "error");
},
});
const updateMutation = useMutation({
mutationFn: (payload: { backendId: string; form: ReturnType<typeof buildEditPayload> }) =>
api.updateBackend(payload.backendId, payload.form),
onSuccess: async () => {
setEditingBackend(null);
setEditError("");
pushToast("Backend updated.", "success");
await queryClient.invalidateQueries({ queryKey: ["backends"] });
},
onError: (error) => {
setEditError(error instanceof Error ? error.message : "Backend update failed.");
},
});
const deleteMutation = useMutation({
mutationFn: (backendId: string) => api.deleteBackend(backendId),
onSuccess: async () => {
const deletedName = deleteTarget?.name;
setDeleteTarget(null);
pushToast(deletedName ? `Deleted ${deletedName}.` : "Backend deleted.", "success");
await queryClient.invalidateQueries({ queryKey: ["backends"] });
await queryClient.invalidateQueries({ queryKey: ["placement-policies"] });
},
onError: (error) => {
pushToast(error instanceof Error ? error.message : "Delete failed.", "error");
},
});
const items = backendQuery.data?.items ?? [];
const healthyCount = items.filter((backend) => (backend.last_health_status ?? "unknown") === "healthy").length;
const enabledCount = items.filter((backend) => backend.is_enabled).length;
const disabledCount = items.filter((backend) => !backend.is_enabled).length;
function handleCreateBackend(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setFormError("");
let config: Record<string, unknown>;
try {
config = JSON.parse(configText) as Record<string, unknown>;
} catch {
setFormError("Config must be valid JSON.");
return;
}
createMutation.mutate({
name,
type: backendType,
stability_class: stabilityClass,
read_priority: readPriority,
write_priority: writePriority,
config,
});
}
function openEditBackend(backend: BackendSummary) {
setEditingBackend(backend);
setEditName(backend.name);
setEditStabilityClass(backend.stability_class as StabilityClass);
setEditReadPriority(backend.read_priority);
setEditWritePriority(backend.write_priority);
setEditConfigText(JSON.stringify(backend.config ?? {}, null, 2));
setEditSecretText("{}");
setEditError("");
}
function handleUpdateBackend(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!editingBackend) {
return;
}
setEditError("");
let form;
try {
form = buildEditPayload({
name: editName,
stabilityClass: editStabilityClass,
readPriority: editReadPriority,
writePriority: editWritePriority,
configText: editConfigText,
secretText: editSecretText,
});
} catch (error) {
setEditError(error instanceof Error ? error.message : "Config must be valid JSON.");
return;
}
updateMutation.mutate({ backendId: editingBackend.id, form });
}
return (
<div className="compact-page-shell compact-page-shell-wide">
<div className="dashboard-page-grid">
<section className="dashboard-main-column">
<div className="panel-toolbar storage-toolbar">
<div className="section-heading">
<span className="eyebrow">Storage</span>
<h3>Backends</h3>
</div>
<button type="button" onClick={() => setIsCreateOpen(true)}>
Add Backend
</button>
<button className="ghost-button" type="button" onClick={() => runAllChecksMutation.mutate()}>
Run All Checks
</button>
</div>
<div className="summary-strip summary-strip-four-up">
<article className="summary-card">
<span className="metric-label">Backends</span>
@ -54,7 +259,7 @@ export function StoragePage() {
{items.map((backend) => (
<section key={backend.id} className="panel storage-card">
<div className="section-heading">
<span className="eyebrow">{backend.type}</span>
<span className="eyebrow">{formatBackendType(backend.type)}</span>
<h3>{backend.name}</h3>
</div>
<div className="storage-status-row">
@ -76,16 +281,36 @@ export function StoragePage() {
<span>Last checked</span>
<strong>{formatDate(backend.last_health_checked_at)}</strong>
</div>
<div>
<span>Config</span>
<strong>{formatBackendConfig(backend.config)}</strong>
</div>
<div>
<span>Secrets</span>
<strong>{backend.secret_fields.length ? `${backend.secret_fields.length} configured` : "None"}</strong>
</div>
</div>
<div className="toolbar-group storage-actions">
<button className="ghost-button" type="button" onClick={() => checkMutation.mutate(backend.id)}>
Run Check
</button>
<button className="ghost-button" type="button" onClick={() => openEditBackend(backend)}>
Edit
</button>
{backend.is_enabled ? (
<button className="danger-button" type="button" onClick={() => disableMutation.mutate(backend.id)}>
Disable
</button>
) : null}
) : (
<>
<button className="ghost-button" type="button" onClick={() => enableMutation.mutate(backend.id)}>
Enable
</button>
<button className="danger-button" type="button" onClick={() => setDeleteTarget(backend)}>
Delete
</button>
</>
)}
</div>
</section>
))}
@ -126,6 +351,216 @@ export function StoragePage() {
</section>
</aside>
</div>
{isCreateOpen ? (
<Dialog title="Add storage backend" onClose={() => setIsCreateOpen(false)}>
<form className="dialog-form" onSubmit={handleCreateBackend}>
<label>
<span>Type</span>
<select value={backendType} onChange={(event) => handleTypeChange(event.target.value as BackendType)}>
<option value="local_directory">Local directory</option>
<option value="s3">S3</option>
<option value="webdav">OpenList / WebDAV</option>
</select>
</label>
<label>
<span>Name</span>
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="storage-main" />
</label>
<label>
<span>Policy class</span>
<select
value={stabilityClass}
onChange={(event) => setStabilityClass(event.target.value as StabilityClass)}
>
<option value="local">Local</option>
<option value="stable">Stable</option>
<option value="opportunistic">Opportunistic</option>
</select>
</label>
<div className="split-fields">
<label>
<span>Read priority</span>
<input
type="number"
value={readPriority}
onChange={(event) => setReadPriority(Number(event.target.value))}
/>
</label>
<label>
<span>Write priority</span>
<input
type="number"
value={writePriority}
onChange={(event) => setWritePriority(Number(event.target.value))}
/>
</label>
</div>
<label>
<span>Config JSON</span>
<textarea
value={configText}
onChange={(event) => setConfigText(event.target.value)}
rows={backendType === "webdav" ? 8 : 9}
spellCheck={false}
/>
</label>
{formError ? <p className="error-text">{formError}</p> : null}
<div className="dialog-actions">
<button className="ghost-button" type="button" onClick={() => setIsCreateOpen(false)}>
Cancel
</button>
<button type="submit" disabled={createMutation.isPending}>
Create
</button>
</div>
</form>
</Dialog>
) : null}
{editingBackend ? (
<Dialog title="Edit storage backend" onClose={() => setEditingBackend(null)}>
<form className="dialog-form" onSubmit={handleUpdateBackend}>
<label>
<span>Type</span>
<input value={editingBackend.type} disabled />
</label>
<label>
<span>Name</span>
<input value={editName} onChange={(event) => setEditName(event.target.value)} />
</label>
<label>
<span>Policy class</span>
<select
value={editStabilityClass}
onChange={(event) => setEditStabilityClass(event.target.value as StabilityClass)}
>
<option value="local">Local</option>
<option value="stable">Stable</option>
<option value="opportunistic">Opportunistic</option>
</select>
</label>
<div className="split-fields">
<label>
<span>Read priority</span>
<input
type="number"
value={editReadPriority}
onChange={(event) => setEditReadPriority(Number(event.target.value))}
/>
</label>
<label>
<span>Write priority</span>
<input
type="number"
value={editWritePriority}
onChange={(event) => setEditWritePriority(Number(event.target.value))}
/>
</label>
</div>
<label>
<span>Public config JSON</span>
<textarea
value={editConfigText}
onChange={(event) => setEditConfigText(event.target.value)}
rows={8}
spellCheck={false}
/>
</label>
<label>
<span>Secret override JSON</span>
<textarea
value={editSecretText}
onChange={(event) => setEditSecretText(event.target.value)}
rows={4}
spellCheck={false}
/>
</label>
<div className="meta-list compact-meta-list">
<div>
<span>Stored secrets</span>
<strong>{editingBackend.secret_fields.length ? editingBackend.secret_fields.join(", ") : "None"}</strong>
</div>
</div>
{editError ? <p className="error-text">{editError}</p> : null}
<div className="dialog-actions">
<button className="ghost-button" type="button" onClick={() => setEditingBackend(null)}>
Cancel
</button>
<button type="submit" disabled={updateMutation.isPending}>
Save
</button>
</div>
</form>
</Dialog>
) : null}
{deleteTarget ? (
<Dialog title="Delete storage backend" onClose={() => setDeleteTarget(null)}>
<div className="dialog-form">
<div className="empty-state">
<strong>{deleteTarget.name}</strong>
<p className="item-muted">
Delete only works for disabled backends with no stored replicas and no placement policy references.
</p>
</div>
<div className="dialog-actions">
<button className="ghost-button" type="button" onClick={() => setDeleteTarget(null)}>
Cancel
</button>
<button
className="danger-button"
type="button"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(deleteTarget.id)}
>
Delete
</button>
</div>
</div>
</Dialog>
) : null}
</div>
);
}
function buildEditPayload(input: {
name: string;
stabilityClass: StabilityClass;
readPriority: number;
writePriority: number;
configText: string;
secretText: string;
}) {
const publicConfig = JSON.parse(input.configText) as Record<string, unknown>;
const secretConfig = JSON.parse(input.secretText) as Record<string, unknown>;
if (!isObject(publicConfig) || !isObject(secretConfig)) {
throw new Error("Config must be a JSON object.");
}
return {
name: input.name,
stability_class: input.stabilityClass,
read_priority: input.readPriority,
write_priority: input.writePriority,
config: { ...publicConfig, ...secretConfig },
};
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function formatBackendConfig(config: Record<string, unknown> | null) {
if (!config) {
return "Configured";
}
if (typeof config.base_path === "string") {
return config.base_path;
}
if (typeof config.bucket === "string") {
const prefix = typeof config.prefix === "string" && config.prefix ? `/${config.prefix}` : "";
return `${config.bucket}${prefix}`;
}
if (typeof config.endpoint_url === "string") {
const rootPath = typeof config.root_path === "string" ? config.root_path : "iron";
return `${config.endpoint_url}/${rootPath}`;
}
return "Configured";
}

186
frontend/src/styles/app.css

@ -40,6 +40,7 @@ body {
button,
input,
select,
textarea,
iframe,
video {
font: inherit;
@ -72,7 +73,8 @@ button:disabled {
}
input,
select {
select,
textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: 12px;
@ -81,6 +83,13 @@ select {
color: var(--ink);
}
textarea {
resize: vertical;
min-height: 180px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.88rem;
}
.ghost-button,
.crumb-button,
.tree-toggle,
@ -924,6 +933,60 @@ select {
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
}
.storage-summary-card {
display: grid;
gap: 0;
}
.storage-summary-card > div,
.replica-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.8rem;
padding: 0.66rem 0;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
}
.storage-summary-card > div > span {
color: var(--muted);
font-size: 0.82rem;
font-weight: 700;
}
.storage-summary-card > div > strong {
text-align: right;
}
.replica-list {
display: grid;
gap: 0;
}
.replica-row > div {
min-width: 0;
display: grid;
gap: 0.18rem;
}
.replica-row strong {
overflow-wrap: anywhere;
}
.replica-warning-card {
border-color: rgba(217, 119, 6, 0.18);
background: rgba(255, 251, 235, 0.98);
}
.replica-warning-card strong {
display: block;
margin-top: 0.72rem;
}
.replica-warning-card p {
margin: 0.18rem 0 0.72rem;
}
.notice-text {
color: var(--success);
}
@ -1024,6 +1087,127 @@ select {
gap: 0.75rem;
}
.dialog-form {
display: grid;
gap: 0.85rem;
}
.dialog-form label {
display: grid;
gap: 0.35rem;
color: var(--muted);
font-size: 0.82rem;
font-weight: 700;
}
.split-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.storage-toolbar {
margin-bottom: 1rem;
flex-wrap: wrap;
}
.policy-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0 0 1rem;
}
.policy-tab {
color: var(--ink);
background: rgba(255, 255, 255, 0.8);
border: 1px solid var(--border);
box-shadow: none;
}
.policy-tab.active {
color: #f8fbff;
background: linear-gradient(180deg, #2d6df6 0%, var(--accent-strong) 100%);
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.14);
}
.policy-editor {
display: grid;
gap: 1rem;
}
.policy-editor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.policy-editor-grid label {
display: grid;
gap: 0.35rem;
color: var(--muted);
font-size: 0.82rem;
font-weight: 700;
}
.checkbox-field {
display: inline-flex !important;
align-items: center;
gap: 0.55rem !important;
color: var(--ink) !important;
}
.checkbox-field input {
width: 18px;
height: 18px;
flex: 0 0 auto;
}
.backend-policy-list,
.decision-list {
display: grid;
gap: 0.65rem;
}
.backend-policy-row,
.decision-backend {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.72rem 0;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
}
.backend-policy-row > div:first-child,
.decision-backend {
min-width: 0;
}
.backend-policy-row strong,
.decision-backend strong {
display: block;
overflow-wrap: anywhere;
}
.backend-policy-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.7rem;
}
.compact-checkbox {
font-size: 0.82rem;
font-weight: 700;
}
.decision-backend span {
color: var(--muted);
font-size: 0.82rem;
font-weight: 700;
}
.filter-bar {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;

2
pyproject.toml

@ -13,6 +13,7 @@ dependencies = [
"alembic>=1.13.2",
"anyio>=4.0.0",
"boto3>=1.35.0",
"cryptography>=42.0.0",
"fastapi>=0.115.0",
"greenlet>=3.1.1",
"sqlalchemy>=2.0.36",
@ -22,6 +23,7 @@ dependencies = [
[project.optional-dependencies]
dev = [
"httpx>=0.27.2",
"pillow>=11.0.0",
"playwright>=1.52.0",
"pytest>=8.3.3",
"pytest-asyncio>=0.24.0",

155
scripts/generate_readme_demo.py

@ -0,0 +1,155 @@
from __future__ import annotations
import shutil
import sys
from pathlib import Path
from PIL import Image, ImageOps
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.ui_playwright_smoke import (
OUTPUT_DIR,
create_temp_preview_file,
create_temp_upload_file,
ensure_frontend_built,
find_free_port,
prepare_database,
start_server,
wait_for_server,
)
DEMO_DIR = ROOT / "output" / "readme-demo"
RUNTIME_DIR = DEMO_DIR / "runtime"
ASSET_DIR = ROOT / "docs" / "assets"
GIF_PATH = ASSET_DIR / "iron-demo.gif"
FRAME_SIZE = (1200, 760)
FRAME_DURATION_MS = 1300
def main() -> int:
DEMO_DIR.mkdir(parents=True, exist_ok=True)
ASSET_DIR.mkdir(parents=True, exist_ok=True)
if RUNTIME_DIR.exists():
shutil.rmtree(RUNTIME_DIR)
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
ensure_frontend_built()
port = find_free_port()
base_url = f"http://127.0.0.1:{port}"
runtime_db = RUNTIME_DIR / "readme-demo.db"
prepare_database(runtime_db)
process = start_server(port, runtime_db)
try:
wait_for_server(base_url)
frame_paths = capture_demo_frames(base_url)
build_gif(frame_paths, GIF_PATH)
finally:
process.terminate()
try:
process.wait(timeout=10)
except Exception:
process.kill()
print(f"README demo GIF created at {GIF_PATH}")
return 0
def capture_demo_frames(base_url: str) -> list[Path]:
frame_paths: list[Path] = []
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1440, "height": 1024}, device_scale_factor=1)
page = context.new_page()
page.goto(f"{base_url}/app/login", wait_until="networkidle")
frame_paths.append(capture_frame(page, "01-login"))
page.get_by_label("Username").fill("admin")
page.get_by_label("Password").fill("changeme-iron")
page.get_by_role("button", name="Sign in").click()
page.wait_for_url(f"{base_url}/app/files")
page.locator(".files-header .crumb-button").filter(has_text="/").first.wait_for()
frame_paths.append(capture_frame(page, "02-files"))
page.locator(".panel-toolbar").get_by_role("button", name="New").click()
page.get_by_role("dialog").get_by_label("Name").fill("demo-folder")
page.get_by_role("dialog").get_by_role("button", name="Confirm").click()
page.locator(".drive-item strong", has_text="demo-folder").first.wait_for()
frame_paths.append(capture_frame(page, "03-folder"))
temp_upload = create_temp_upload_file()
preview_upload = create_temp_preview_file()
try:
page.locator(".files-command-bar").get_by_role("button", name="Upload", exact=True).click()
page.locator("input[type='file']").set_input_files(str(temp_upload))
page.locator(".drive-item strong", has_text="playwright-upload.txt").first.wait_for(timeout=15000)
page.locator(".files-command-bar").get_by_role("button", name="Upload", exact=True).click()
page.locator("input[type='file']").set_input_files(str(preview_upload))
page.locator(".drive-item strong", has_text="playwright-preview.png").first.wait_for(timeout=15000)
finally:
temp_upload.unlink(missing_ok=True)
preview_upload.unlink(missing_ok=True)
frame_paths.append(capture_frame(page, "04-upload"))
page.locator(".drive-item strong", has_text="playwright-preview.png").first.click()
page.locator("img.preview-frame").wait_for(timeout=15000)
frame_paths.append(capture_frame(page, "05-preview"))
page.get_by_role("button", name="Storage").click()
page.get_by_role("button", name="Reconcile File").wait_for()
frame_paths.append(capture_frame(page, "06-storage"))
page.goto(f"{base_url}/app/storage", wait_until="networkidle")
frame_paths.append(capture_frame(page, "07-backends"))
page.goto(f"{base_url}/app/policies", wait_until="networkidle")
page.get_by_role("button", name="Save Policy").wait_for()
frame_paths.append(capture_frame(page, "08-policies"))
page.goto(f"{base_url}/app/jobs", wait_until="networkidle")
frame_paths.append(capture_frame(page, "09-jobs"))
context.close()
browser.close()
return frame_paths
def capture_frame(page, name: str) -> Path: # type: ignore[no-untyped-def]
path = DEMO_DIR / f"{name}.png"
page.screenshot(path=str(path), full_page=True)
return path
def build_gif(frame_paths: list[Path], output_path: Path) -> None:
frames = []
for frame_path in frame_paths:
with Image.open(frame_path) as image:
framed = ImageOps.contain(image.convert("RGB"), FRAME_SIZE)
canvas = Image.new("RGB", FRAME_SIZE, color=(245, 247, 250))
offset = ((FRAME_SIZE[0] - framed.width) // 2, (FRAME_SIZE[1] - framed.height) // 2)
canvas.paste(framed, offset)
frames.append(canvas)
if not frames:
raise RuntimeError("No frames captured for README demo GIF.")
frames[0].save(
output_path,
save_all=True,
append_images=frames[1:],
duration=FRAME_DURATION_MS,
loop=0,
optimize=True,
disposal=2,
)
if __name__ == "__main__":
raise SystemExit(main())

320
scripts/real_openlist_acceptance.py

@ -0,0 +1,320 @@
from __future__ import annotations
import base64
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
def main() -> int:
config = AcceptanceConfig.from_env()
client = IronClient(config)
file_class = os.getenv("IRON_ACCEPTANCE_FILE_CLASS", "other")
print(f"[1/7] logging into Iron at {config.iron_base_url}")
client.login()
print(f"[2/7] ensuring backend {config.backend_name!r}")
backend = client.ensure_webdav_backend()
print(f" backend id: {backend['id']}")
print("[3/7] running backend health check")
check = client.check_backend(backend["id"])
if check["status"] != "healthy":
raise RuntimeError(f"backend health check failed: {json.dumps(check, ensure_ascii=False)}")
print(f" healthy: {check['detail']}")
original_policy = next(policy for policy in client.list_policies() if policy["file_class"] == file_class)
client.upsert_policy(
file_class,
require_local=True,
stable_replica_count=1,
opportunistic_replica_count=0,
preferred_backend_ids=[backend["id"]],
excluded_backend_ids=[],
max_non_local_size_bytes=None,
)
try:
print("[4/7] uploading acceptance file through Iron")
file_id = client.upload_text_file()
print(f" file id: {file_id}")
print("[5/7] waiting for local + OpenList replicas to become ready")
detail = client.wait_for_ready_replicas(file_id, backend["id"])
print(f" replicas ready: {detail['ready_replica_backend_ids']}")
print("[6/7] verifying download through Iron")
downloaded = client.download_file(file_id)
if downloaded != config.test_content.encode("utf-8"):
raise RuntimeError("downloaded bytes do not match uploaded content")
print(f" download ok: {len(downloaded)} bytes")
print("[7/7] verifying direct WebDAV read from OpenList")
webdav_replica = next(
replica for replica in detail["replicas"] if replica["backend_id"] == backend["id"] and replica["status"] == "ready"
)
dav_bytes = read_openlist_webdav(
endpoint_url=config.openlist_dav_url,
username=config.openlist_username,
password=config.openlist_password,
relative_path=f"{config.openlist_root_path}/{webdav_replica['storage_key']}",
)
if dav_bytes != config.test_content.encode("utf-8"):
raise RuntimeError("OpenList WebDAV bytes do not match uploaded content")
print(f" webdav ok: {webdav_replica['storage_key']}")
print("Acceptance passed.")
return 0
finally:
client.upsert_policy(
file_class,
require_local=bool(original_policy["require_local"]),
stable_replica_count=int(original_policy["stable_replica_count"]),
opportunistic_replica_count=int(original_policy["opportunistic_replica_count"]),
preferred_backend_ids=list(original_policy["preferred_backend_ids"]),
excluded_backend_ids=list(original_policy["excluded_backend_ids"]),
max_non_local_size_bytes=original_policy["max_non_local_size_bytes"],
)
@dataclass
class AcceptanceConfig:
iron_base_url: str
iron_username: str
iron_password: str
openlist_dav_url: str
openlist_username: str
openlist_password: str
openlist_root_path: str
backend_name: str
directory_id: str
test_filename: str
test_content: str
reconcile_timeout_seconds: float
reconcile_poll_interval_seconds: float
http_timeout_seconds: float
@classmethod
def from_env(cls) -> "AcceptanceConfig":
default_filename = f"real-openlist-acceptance-{int(time.time())}.txt"
return cls(
iron_base_url=os.getenv("IRON_BASE_URL", "http://127.0.0.1:8000").rstrip("/"),
iron_username=os.getenv("IRON_USERNAME", "admin"),
iron_password=os.getenv("IRON_PASSWORD", "changeme-iron"),
openlist_dav_url=os.getenv("OPENLIST_DAV_URL", "http://127.0.0.1:5244/dav").rstrip("/"),
openlist_username=os.getenv("OPENLIST_USERNAME", "iron"),
openlist_password=os.getenv("OPENLIST_PASSWORD", "iron"),
openlist_root_path=os.getenv("OPENLIST_ROOT_PATH", "aliyun/iron").strip("/"),
backend_name=os.getenv("IRON_OPENLIST_BACKEND_NAME", "openlist-aliyun"),
directory_id=os.getenv("IRON_TEST_DIRECTORY_ID", "dir_root"),
test_filename=os.getenv("IRON_TEST_FILENAME", default_filename),
test_content=os.getenv("IRON_TEST_CONTENT", "real-openlist-acceptance\n"),
reconcile_timeout_seconds=float(os.getenv("IRON_RECONCILE_TIMEOUT_SECONDS", "45")),
reconcile_poll_interval_seconds=float(os.getenv("IRON_RECONCILE_POLL_INTERVAL_SECONDS", "2")),
http_timeout_seconds=float(os.getenv("IRON_HTTP_TIMEOUT_SECONDS", "30")),
)
class IronClient:
def __init__(self, config: AcceptanceConfig) -> None:
self.config = config
self.token: str | None = None
def login(self) -> None:
response = self._request(
"POST",
"/api/auth/login",
json_body={"username": self.config.iron_username, "password": self.config.iron_password},
)
self.token = response["access_token"]
def ensure_webdav_backend(self) -> dict[str, object]:
items = self.list_backends()
for backend in items:
if backend["name"] == self.config.backend_name:
return backend
payload = {
"name": self.config.backend_name,
"type": "webdav",
"stability_class": "stable",
"read_priority": 80,
"write_priority": 80,
"config": {
"endpoint_url": self.config.openlist_dav_url,
"username": self.config.openlist_username,
"password": self.config.openlist_password,
"root_path": self.config.openlist_root_path,
"verify_ssl": False,
},
}
response = self._request("POST", "/api/backends", json_body=payload)
return response["backend"]
def list_backends(self) -> list[dict[str, object]]:
return self._request("GET", "/api/backends")["items"]
def check_backend(self, backend_id: str) -> dict[str, object]:
return self._request("POST", f"/api/backends/{backend_id}/check")
def disable_backend(self, backend_id: str) -> dict[str, object]:
return self._request("POST", f"/api/backends/{backend_id}/disable")["backend"]
def enable_backend(self, backend_id: str) -> dict[str, object]:
return self._request("POST", f"/api/backends/{backend_id}/enable")["backend"]
def list_policies(self) -> list[dict[str, object]]:
return self._request("GET", "/api/policies/placement")["items"]
def list_jobs(self) -> list[dict[str, object]]:
return self._request("GET", "/api/jobs")["items"]
def upsert_policy(
self,
file_class: str,
*,
require_local: bool,
stable_replica_count: int,
opportunistic_replica_count: int,
preferred_backend_ids: list[str],
excluded_backend_ids: list[str],
max_non_local_size_bytes: int | None,
) -> dict[str, object]:
payload = {
"require_local": require_local,
"stable_replica_count": stable_replica_count,
"opportunistic_replica_count": opportunistic_replica_count,
"preferred_backend_ids": preferred_backend_ids,
"excluded_backend_ids": excluded_backend_ids,
"max_non_local_size_bytes": max_non_local_size_bytes,
}
return self._request("PUT", f"/api/policies/placement/{file_class}", json_body=payload)["policy"]
def upload_text_file(self) -> str:
return self.upload_bytes(
filename=self.config.test_filename,
content=self.config.test_content.encode("utf-8"),
directory_id=self.config.directory_id,
)
def upload_bytes(self, *, filename: str, content: bytes, directory_id: str) -> str:
metadata_value = base64.b64encode(filename.encode("utf-8")).decode("ascii")
headers = {
"Tus-Resumable": "1.0.0",
"Upload-Length": str(len(content)),
"Upload-Metadata": f"filename {metadata_value}",
}
create_response, create_headers = self._raw_request("POST", "/files", headers=headers)
_ = create_response
location = header_value(create_headers, "Location")
upload_id = location.rsplit("/", 1)[-1]
patch_headers = {
"Tus-Resumable": "1.0.0",
"Upload-Offset": "0",
"Content-Type": "application/offset+octet-stream",
}
self._raw_request("PATCH", f"/files/{upload_id}", headers=patch_headers, data=content)
finalize = self._request(
"POST",
f"/api/uploads/{upload_id}/finalize",
json_body={"directory_id": directory_id, "filename": filename},
)
return finalize["file"]["id"]
def wait_for_ready_replicas(self, file_id: str, backend_id: str) -> dict[str, object]:
return self.wait_for_ready_replica_set(file_id, {"bkd_local_default", backend_id})
def wait_for_ready_replica_set(self, file_id: str, backend_ids: set[str]) -> dict[str, object]:
deadline = time.time() + self.config.reconcile_timeout_seconds
detail: dict[str, object] = {}
while time.time() < deadline:
self.reconcile_file(file_id)
self.run_pending_jobs()
detail = self.file_detail(file_id)
ready_ids = set(detail["ready_replica_backend_ids"])
if backend_ids.issubset(ready_ids):
return detail
time.sleep(self.config.reconcile_poll_interval_seconds)
raise RuntimeError(f"timed out waiting for replicas: {json.dumps(detail, ensure_ascii=False)}")
def download_file(self, file_id: str) -> bytes:
body, _headers = self._raw_request("GET", f"/api/files/{file_id}/download")
return body
def file_detail(self, file_id: str) -> dict[str, object]:
return self._request("GET", f"/api/files/{file_id}")
def stream_file(self, file_id: str, *, range_header: str | None = None) -> tuple[bytes, dict[str, str]]:
headers = {"Range": range_header} if range_header else {}
return self._raw_request("GET", f"/api/files/{file_id}/stream", headers=headers)
def delete_file(self, file_id: str) -> dict[str, object]:
return self._request("DELETE", f"/api/files/{file_id}")
def restore_file(self, file_id: str) -> dict[str, object]:
return self._request("POST", f"/api/files/{file_id}/restore")
def list_recycle_bin(self) -> list[dict[str, object]]:
return self._request("GET", "/api/files/recycle-bin")["items"]
def reconcile_file(self, file_id: str) -> dict[str, object]:
return self._request("POST", f"/api/files/{file_id}/reconcile")
def run_pending_jobs(self) -> dict[str, object]:
return self._request("POST", "/api/jobs/run-pending")
def _request(self, method: str, path: str, json_body: dict[str, object] | None = None) -> dict[str, object]:
payload = None if json_body is None else json.dumps(json_body).encode("utf-8")
headers = {"Content-Type": "application/json"} if payload is not None else {}
body, _response_headers = self._raw_request(method, path, headers=headers, data=payload)
return json.loads(body.decode("utf-8")) if body else {}
def _raw_request(
self,
method: str,
path: str,
headers: dict[str, str] | None = None,
data: bytes | None = None,
) -> tuple[bytes, dict[str, str]]:
merged_headers = dict(headers or {})
if self.token:
merged_headers["Authorization"] = f"Bearer {self.token}"
request = urllib.request.Request(
self.config.iron_base_url + path,
method=method,
data=data,
headers=merged_headers,
)
try:
with urllib.request.urlopen(request, timeout=self.config.http_timeout_seconds) as response:
return response.read(), dict(response.headers.items())
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"{method} {path} failed with {exc.code}: {body}") from exc
def read_openlist_webdav(endpoint_url: str, username: str, password: str, relative_path: str) -> bytes:
token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
path = "/".join(urllib.parse.quote(part, safe="") for part in relative_path.strip("/").split("/") if part)
request = urllib.request.Request(
f"{endpoint_url.rstrip('/')}/{path}",
method="GET",
headers={"Authorization": f"Basic {token}"},
)
with urllib.request.urlopen(request, timeout=30) as response:
return response.read()
def header_value(headers: dict[str, str], name: str) -> str:
for key, value in headers.items():
if key.lower() == name.lower():
return value
raise KeyError(name)
if __name__ == "__main__":
raise SystemExit(main())

173
scripts/real_openlist_chaos_acceptance.py

@ -0,0 +1,173 @@
from __future__ import annotations
import hashlib
import os
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.real_openlist_acceptance import AcceptanceConfig, IronClient, header_value
def main() -> int:
config = AcceptanceConfig.from_env()
client = IronClient(config)
container = os.getenv("OPENLIST_DOCKER_CONTAINER", "openlist")
file_class = os.getenv("IRON_CHAOS_FILE_CLASS", "other")
size_bytes = int(os.getenv("IRON_CHAOS_FILE_SIZE_BYTES", str(8 * 1024 * 1024)))
flap_count = int(os.getenv("IRON_CHAOS_FLAP_COUNT", "2"))
print(f"[1/10] logging into Iron at {config.iron_base_url}")
client.login()
print(f"[2/10] ensuring backend {config.backend_name!r}")
backend = client.ensure_webdav_backend()
healthy = client.check_backend(backend["id"])
if healthy["status"] != "healthy":
raise RuntimeError(f"backend unhealthy before chaos test: {healthy}")
original_policy = next(policy for policy in client.list_policies() if policy["file_class"] == file_class)
client.upsert_policy(
file_class,
require_local=True,
stable_replica_count=1,
opportunistic_replica_count=0,
preferred_backend_ids=[backend["id"]],
excluded_backend_ids=[],
max_non_local_size_bytes=None,
)
try:
print(f"[3/10] uploading large file ({size_bytes} bytes)")
content = build_deterministic_bytes(size_bytes)
filename = f"chaos-large-{int(time.time())}.bin"
file_id = client.upload_bytes(filename=filename, content=content, directory_id=config.directory_id)
print("[4/10] waiting for local + remote replicas")
detail = client.wait_for_ready_replica_set(file_id, {"bkd_local_default", backend["id"]})
print(f" ready replicas: {detail['ready_replica_backend_ids']}")
print("[5/10] validating ranged stream reads")
validate_stream_ranges(client, file_id, content)
print(" ranged stream checks passed")
print("[6/10] forcing remote recovery after deleting local physical file")
local_replica = next(replica for replica in detail["replicas"] if replica["backend_id"] == "bkd_local_default")
local_base_path = local_backend_base_path(client)
local_file_path = Path(local_base_path) / local_replica["storage_key"]
cache_file_path = default_cache_root() / local_replica["storage_key"]
if local_file_path.exists():
local_file_path.unlink()
if cache_file_path.exists():
cache_file_path.unlink()
recovered = client.download_file(file_id)
if recovered != content:
raise RuntimeError("remote recovery bytes do not match original content")
print(f" remote recovery restored cache at {cache_file_path}")
print(f"[7/10] flapping OpenList {flap_count} times")
for index in range(flap_count):
docker(["stop", container])
wait_for_unhealthy(client, backend["id"])
docker(["start", container])
wait_for_healthy(client, backend["id"])
print(f" flap {index + 1}/{flap_count} recovered")
print("[8/10] deleting file to recycle bin and restoring it")
client.delete_file(file_id)
recycle_items = client.list_recycle_bin()
if not any(item["id"] == file_id for item in recycle_items):
raise RuntimeError("deleted file not found in recycle bin")
client.restore_file(file_id)
detail = client.file_detail(file_id)
if detail["file"]["id"] != file_id:
raise RuntimeError("restored file detail did not match original file")
print(" restore succeeded")
print("[9/10] reconciling after restore and validating download")
detail = client.wait_for_ready_replica_set(file_id, {"bkd_local_default", backend["id"]})
restored_bytes = client.download_file(file_id)
if restored_bytes != content:
raise RuntimeError("restored file download does not match original content")
print(f" replicas after restore: {detail['ready_replica_backend_ids']}")
print("[10/10] final integrity digest")
print(f" sha256: {hashlib.sha256(content).hexdigest()}")
print("Chaos acceptance passed.")
return 0
finally:
client.upsert_policy(
file_class,
require_local=bool(original_policy["require_local"]),
stable_replica_count=int(original_policy["stable_replica_count"]),
opportunistic_replica_count=int(original_policy["opportunistic_replica_count"]),
preferred_backend_ids=list(original_policy["preferred_backend_ids"]),
excluded_backend_ids=list(original_policy["excluded_backend_ids"]),
max_non_local_size_bytes=original_policy["max_non_local_size_bytes"],
)
def build_deterministic_bytes(size_bytes: int) -> bytes:
chunk = hashlib.sha256(b"iron-chaos-seed").digest()
repeats = (size_bytes // len(chunk)) + 1
return (chunk * repeats)[:size_bytes]
def validate_stream_ranges(client: IronClient, file_id: str, content: bytes) -> None:
checks = [
(0, 255),
(1024, 2047),
(len(content) // 2, (len(content) // 2) + 511),
(len(content) - 512, len(content) - 1),
]
for start, end in checks:
body, headers = client.stream_file(file_id, range_header=f"bytes={start}-{end}")
if body != content[start : end + 1]:
raise RuntimeError(f"range body mismatch for {start}-{end}")
content_range = header_value(headers, "Content-Range")
if content_range != f"bytes {start}-{end}/{len(content)}":
raise RuntimeError(f"unexpected content-range for {start}-{end}: {content_range}")
def wait_for_unhealthy(client: IronClient, backend_id: str, timeout_seconds: float = 30) -> None:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
result = client.check_backend(backend_id)
if result["status"] == "unhealthy":
return
time.sleep(1)
raise RuntimeError("backend did not become unhealthy in time")
def wait_for_healthy(client: IronClient, backend_id: str, timeout_seconds: float = 45) -> None:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
result = client.check_backend(backend_id)
if result["status"] == "healthy":
return
time.sleep(1)
raise RuntimeError("backend did not recover to healthy in time")
def local_backend_base_path(client: IronClient) -> str:
for backend in client.list_backends():
if backend["id"] == "bkd_local_default":
return str(backend["config"]["base_path"])
raise RuntimeError("local-default backend not found")
def default_cache_root() -> Path:
return Path(os.getenv("IRON_CACHE_DIR", ".iron-cache"))
def docker(args: list[str]) -> None:
subprocess.run(["docker", *args], cwd=ROOT, check=True)
if __name__ == "__main__":
raise SystemExit(main())

208
scripts/real_openlist_multi_backend_acceptance.py

@ -0,0 +1,208 @@
from __future__ import annotations
import json
import os
import sys
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.real_openlist_acceptance import AcceptanceConfig, IronClient, read_openlist_webdav
def main() -> int:
config = MultiBackendConfig.from_env()
client = IronClient(config.base)
print(f"[1/9] logging into Iron at {config.base.iron_base_url}")
client.login()
print("[2/9] ensuring multi-backend OpenList configuration")
backends = ensure_backends(client, config)
for backend in backends:
check = client.check_backend(backend["id"])
if check["status"] != "healthy":
raise RuntimeError(f"backend {backend['name']} unhealthy: {json.dumps(check, ensure_ascii=False)}")
print(f" {backend['name']}: healthy at {check['detail']}")
original_policy = next(policy for policy in client.list_policies() if policy["file_class"] == config.file_class)
print(f"[3/9] saved original {config.file_class!r} policy")
remote_backend_ids = [backend["id"] for backend in backends]
try:
print("[4/9] verifying two-remote replica policy")
client.upsert_policy(
config.file_class,
require_local=True,
stable_replica_count=2,
opportunistic_replica_count=0,
preferred_backend_ids=remote_backend_ids,
excluded_backend_ids=[],
max_non_local_size_bytes=None,
)
first_file_id = client.upload_text_file()
first_detail = client.wait_for_ready_replica_set(first_file_id, {"bkd_local_default", remote_backend_ids[0], remote_backend_ids[1]})
if config.verify_direct_webdav:
assert_remote_bytes(config, backends, first_detail, remote_backend_ids[:2])
print(f" first file ready on local + {remote_backend_ids[0]} + {remote_backend_ids[1]}")
print("[5/9] disabling the first remote backend")
disabled_backend = client.disable_backend(remote_backend_ids[0])
print(f" disabled: {disabled_backend['name']}")
print("[6/9] verifying new uploads avoid the disabled backend")
second_filename = config.base.test_filename.replace(".txt", "-after-disable.txt")
second_content = config.base.test_content.rstrip("\n") + "-after-disable\n"
second_file_id = upload_named_text_file(client, config.base.directory_id, second_filename, second_content)
second_detail = client.wait_for_ready_replica_set(second_file_id, {"bkd_local_default", remote_backend_ids[1], remote_backend_ids[2]})
if config.verify_direct_webdav:
assert_remote_bytes(config, backends, second_detail, remote_backend_ids[1:3], expected_text=second_content)
print(f" second file ready on local + {remote_backend_ids[1]} + {remote_backend_ids[2]}")
print("[7/9] re-enabling the first remote backend")
enabled_backend = client.enable_backend(remote_backend_ids[0])
check = client.check_backend(enabled_backend["id"])
if check["status"] != "healthy":
raise RuntimeError(f"re-enabled backend not healthy: {json.dumps(check, ensure_ascii=False)}")
print(f" re-enabled: {enabled_backend['name']}")
print("[8/9] verifying three-remote replica policy")
client.upsert_policy(
config.file_class,
require_local=True,
stable_replica_count=3,
opportunistic_replica_count=0,
preferred_backend_ids=remote_backend_ids,
excluded_backend_ids=[],
max_non_local_size_bytes=None,
)
third_filename = config.base.test_filename.replace(".txt", "-all-three.txt")
third_content = config.base.test_content.rstrip("\n") + "-all-three\n"
third_file_id = upload_named_text_file(client, config.base.directory_id, third_filename, third_content)
third_detail = client.wait_for_ready_replica_set(third_file_id, {"bkd_local_default", *remote_backend_ids})
if config.verify_direct_webdav:
assert_remote_bytes(config, backends, third_detail, remote_backend_ids, expected_text=third_content)
print(" third file ready on local + all remote backends")
finally:
print("[9/9] restoring original policy")
client.upsert_policy(
config.file_class,
require_local=bool(original_policy["require_local"]),
stable_replica_count=int(original_policy["stable_replica_count"]),
opportunistic_replica_count=int(original_policy["opportunistic_replica_count"]),
preferred_backend_ids=list(original_policy["preferred_backend_ids"]),
excluded_backend_ids=list(original_policy["excluded_backend_ids"]),
max_non_local_size_bytes=original_policy["max_non_local_size_bytes"],
)
print(" original policy restored")
print("Multi-backend acceptance passed.")
return 0
@dataclass
class MultiBackendConfig:
base: AcceptanceConfig
backend_names: list[str]
root_paths: list[str]
file_class: str
verify_direct_webdav: bool
@classmethod
def from_env(cls) -> "MultiBackendConfig":
base = AcceptanceConfig.from_env()
raw_paths = os.getenv("OPENLIST_MULTI_ROOT_PATHS", "aliyun/iron,aliyun/iron-b,aliyun/iron-c")
root_paths = [item.strip().strip("/") for item in raw_paths.split(",") if item.strip()]
if len(root_paths) < 3:
raise RuntimeError("OPENLIST_MULTI_ROOT_PATHS must define at least three root paths.")
backend_names = [
"openlist-aliyun",
"openlist-aliyun-b",
"openlist-aliyun-c",
*[
f"openlist-aliyun-{index}"
for index in range(4, len(root_paths) + 1)
],
][: len(root_paths)]
return cls(
base=base,
backend_names=backend_names,
root_paths=root_paths,
file_class=os.getenv("IRON_MULTI_FILE_CLASS", "other"),
verify_direct_webdav=os.getenv("IRON_MULTI_VERIFY_DIRECT_WEBDAV", "").strip().lower() in {"1", "true", "yes", "on"},
)
def ensure_backends(client: IronClient, config: MultiBackendConfig) -> list[dict[str, object]]:
existing = {backend["name"]: backend for backend in client.list_backends()}
ensured: list[dict[str, object]] = []
for index, (name, root_path) in enumerate(zip(config.backend_names, config.root_paths, strict=True), start=1):
backend = existing.get(name)
if backend is None:
payload = {
"name": name,
"type": "webdav",
"stability_class": "stable",
"read_priority": 90 - index,
"write_priority": 90 - index,
"config": {
"endpoint_url": config.base.openlist_dav_url,
"username": config.base.openlist_username,
"password": config.base.openlist_password,
"root_path": root_path,
"verify_ssl": False,
},
}
backend = client._request("POST", "/api/backends", json_body=payload)["backend"]
ensured.append(backend)
return ensured
def upload_named_text_file(client: IronClient, directory_id: str, filename: str, content: str) -> str:
original_filename = client.config.test_filename
original_content = client.config.test_content
try:
client.config.test_filename = filename
client.config.test_content = content
return client.upload_text_file()
finally:
client.config.test_filename = original_filename
client.config.test_content = original_content
def assert_remote_bytes(
config: MultiBackendConfig,
backends: list[dict[str, object]],
detail: dict[str, object],
backend_ids: list[str],
*,
expected_text: str | None = None,
) -> None:
expected_bytes = (expected_text or config.base.test_content).encode("utf-8")
replicas = detail["replicas"]
backend_lookup = {backend["id"]: backend for backend in backends}
for backend_id in backend_ids:
replica = next(rep for rep in replicas if rep["backend_id"] == backend_id and rep["status"] == "ready")
backend = backend_lookup[backend_id]
try:
body = read_openlist_webdav(
endpoint_url=config.base.openlist_dav_url,
username=config.base.openlist_username,
password=config.base.openlist_password,
relative_path=f"{backend['config']['root_path']}/{replica['storage_key']}",
)
except Exception as exc:
raise RuntimeError(
f"direct WebDAV read failed for backend {backend['name']} at "
f"{backend['config']['root_path']}/{replica['storage_key']}: {exc}"
) from exc
if body != expected_bytes:
raise RuntimeError(f"unexpected bytes for backend {backend['name']}")
if __name__ == "__main__":
raise SystemExit(main())

154
scripts/real_openlist_multi_backend_chaos.py

@ -0,0 +1,154 @@
from __future__ import annotations
import hashlib
import os
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.real_openlist_chaos_acceptance import default_cache_root, local_backend_base_path
from scripts.real_openlist_multi_backend_acceptance import MultiBackendConfig, ensure_backends
from scripts.real_openlist_acceptance import IronClient
def main() -> int:
config = MultiBackendConfig.from_env()
client = IronClient(config.base)
file_class = os.getenv("IRON_MULTI_CHAOS_FILE_CLASS", config.file_class)
size_bytes = int(os.getenv("IRON_MULTI_CHAOS_FILE_SIZE_BYTES", str(2 * 1024 * 1024)))
print(f"[1/11] logging into Iron at {config.base.iron_base_url}")
client.login()
print("[2/11] ensuring three OpenList/Aliyun backends")
backends = ensure_backends(client, config)
for backend in backends[:3]:
check = client.check_backend(backend["id"])
if check["status"] != "healthy":
raise RuntimeError(f"backend {backend['name']} unhealthy: {check}")
print(f" {backend['name']}: healthy")
backends = backends[:3]
backend_ids = [backend["id"] for backend in backends]
original_policy = next(policy for policy in client.list_policies() if policy["file_class"] == file_class)
try:
print("[3/11] forcing two stable remote replicas across three backends")
client.upsert_policy(
file_class,
require_local=True,
stable_replica_count=2,
opportunistic_replica_count=0,
preferred_backend_ids=backend_ids,
excluded_backend_ids=[],
max_non_local_size_bytes=None,
)
print(f"[4/11] uploading degraded-mode probe file ({size_bytes} bytes)")
content = build_deterministic_bytes(size_bytes)
file_id = client.upload_bytes(
filename=f"multi-chaos-{int(time.time())}.bin",
content=content,
directory_id=config.base.directory_id,
)
detail = client.wait_for_ready_replica_set(file_id, {"bkd_local_default", backend_ids[0], backend_ids[1]})
print(f" initial replicas: {detail['ready_replica_backend_ids']}")
print("[5/11] disabling the first remote backend and confirming it stays out of placement")
client.disable_backend(backend_ids[0])
disabled_detail = client.file_detail(file_id)
print(f" disabled backend: {backends[0]['name']}")
print("[6/11] forcing remote recovery while one backend is disabled")
local_replica = next(replica for replica in disabled_detail["replicas"] if replica["backend_id"] == "bkd_local_default")
local_path = Path(local_backend_base_path(client)) / local_replica["storage_key"]
cache_path = default_cache_root() / local_replica["storage_key"]
if local_path.exists():
local_path.unlink()
if cache_path.exists():
cache_path.unlink()
recovered = client.download_file(file_id)
if recovered != content:
raise RuntimeError("recovered bytes did not match original content")
print(" remote recovery succeeded from surviving replicas")
print("[7/11] uploading a second file while the first remote backend is disabled")
degraded_file_id = client.upload_bytes(
filename=f"multi-chaos-degraded-{int(time.time())}.bin",
content=content[::-1],
directory_id=config.base.directory_id,
)
degraded_detail = client.wait_for_ready_replica_set(
degraded_file_id,
{"bkd_local_default", backend_ids[1], backend_ids[2]},
)
ready_ids = set(degraded_detail["ready_replica_backend_ids"])
if backend_ids[0] in ready_ids:
raise RuntimeError("disabled backend unexpectedly received a replica")
print(f" degraded upload replicas: {degraded_detail['ready_replica_backend_ids']}")
print("[8/11] re-enabling the first backend")
client.enable_backend(backend_ids[0])
check = client.check_backend(backend_ids[0])
if check["status"] != "healthy":
raise RuntimeError(f"re-enabled backend unhealthy: {check}")
print(" backend healthy again")
print("[9/11] raising policy to three stable remote replicas and reconciling")
client.upsert_policy(
file_class,
require_local=True,
stable_replica_count=3,
opportunistic_replica_count=0,
preferred_backend_ids=backend_ids,
excluded_backend_ids=[],
max_non_local_size_bytes=None,
)
healed_detail = client.wait_for_ready_replica_set(degraded_file_id, {"bkd_local_default", *backend_ids})
print(f" healed replicas: {healed_detail['ready_replica_backend_ids']}")
print("[10/11] deleting the healed file to recycle bin and restoring it")
client.delete_file(degraded_file_id)
recycle_items = client.list_recycle_bin()
if not any(item["id"] == degraded_file_id for item in recycle_items):
raise RuntimeError("healed file not found in recycle bin")
client.restore_file(degraded_file_id)
restored_detail = client.wait_for_ready_replica_set(degraded_file_id, {"bkd_local_default", *backend_ids})
restored_bytes = client.download_file(degraded_file_id)
if restored_bytes != content[::-1]:
raise RuntimeError("restored multi-backend file bytes did not match")
print(f" restored replicas: {restored_detail['ready_replica_backend_ids']}")
print("[11/11] final integrity digests")
print(f" probe sha256: {hashlib.sha256(content).hexdigest()}")
print(f" degraded sha256: {hashlib.sha256(content[::-1]).hexdigest()}")
print("Multi-backend chaos acceptance passed.")
return 0
finally:
for backend_id in backend_ids:
backend = next((item for item in client.list_backends() if item["id"] == backend_id), None)
if backend is not None and not backend.get("enabled", True):
client.enable_backend(backend_id)
client.upsert_policy(
file_class,
require_local=bool(original_policy["require_local"]),
stable_replica_count=int(original_policy["stable_replica_count"]),
opportunistic_replica_count=int(original_policy["opportunistic_replica_count"]),
preferred_backend_ids=list(original_policy["preferred_backend_ids"]),
excluded_backend_ids=list(original_policy["excluded_backend_ids"]),
max_non_local_size_bytes=original_policy["max_non_local_size_bytes"],
)
def build_deterministic_bytes(size_bytes: int) -> bytes:
chunk = hashlib.sha256(b"iron-multi-chaos-seed").digest()
repeats = (size_bytes // len(chunk)) + 1
return (chunk * repeats)[:size_bytes]
if __name__ == "__main__":
raise SystemExit(main())

40
scripts/real_openlist_suite.py

@ -0,0 +1,40 @@
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SCENARIOS = {
"basic": "scripts/real_openlist_acceptance.py",
"single_chaos": "scripts/real_openlist_chaos_acceptance.py",
"multi_basic": "scripts/real_openlist_multi_backend_acceptance.py",
"multi_chaos": "scripts/real_openlist_multi_backend_chaos.py",
}
def main() -> int:
selected = os.getenv(
"IRON_ACCEPTANCE_SCENARIOS",
"basic,single_chaos,multi_basic,multi_chaos",
)
scenario_names = [item.strip() for item in selected.split(",") if item.strip()]
if not scenario_names:
raise RuntimeError("IRON_ACCEPTANCE_SCENARIOS did not contain any scenarios.")
for name in scenario_names:
script = SCENARIOS.get(name)
if script is None:
known = ", ".join(sorted(SCENARIOS))
raise RuntimeError(f"unknown scenario {name!r}; expected one of: {known}")
print(f"\n=== running {name}: {script} ===", flush=True)
subprocess.run([sys.executable, script], cwd=ROOT, check=True)
print("\nReal OpenList acceptance suite passed.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

15
scripts/ui_playwright_smoke.py

@ -181,6 +181,9 @@ def run_browser_flow(base_url: str) -> None:
page.locator(".drive-item strong", has_text="playwright-preview.png").first.click()
page.locator("img.preview-frame").wait_for(timeout=15000)
page.screenshot(path=str(OUTPUT_DIR / "files-preview-selected.png"), full_page=True)
page.get_by_role("button", name="Storage").click()
page.get_by_role("button", name="Reconcile File").wait_for()
page.screenshot(path=str(OUTPUT_DIR / "files-storage-selected.png"), full_page=True)
page.get_by_label("Search current directory").fill("playwright")
page.locator(".drive-item strong", has_text="playwright-folder").first.wait_for()
@ -193,8 +196,20 @@ def run_browser_flow(base_url: str) -> None:
page.screenshot(path=str(OUTPUT_DIR / "recycle-bin-page.png"), full_page=True)
page.goto(f"{base_url}/app/storage", wait_until="networkidle")
page.get_by_role("button", name="Run All Checks").click()
page.locator(".toast-item").first.wait_for()
page.get_by_role("button", name="Edit").first.click()
page.get_by_role("dialog").get_by_text("Stored secrets").wait_for()
page.screenshot(path=str(OUTPUT_DIR / "storage-edit-dialog.png"), full_page=True)
page.get_by_role("dialog").get_by_role("button", name="Cancel").click()
page.screenshot(path=str(OUTPUT_DIR / "storage-page.png"), full_page=True)
page.goto(f"{base_url}/app/policies", wait_until="networkidle")
page.get_by_role("button", name="Save Policy").wait_for()
page.get_by_role("button", name="Save Policy").click()
page.locator(".toast-item").first.wait_for()
page.screenshot(path=str(OUTPUT_DIR / "policies-page.png"), full_page=True)
page.goto(f"{base_url}/app/jobs", wait_until="networkidle")
page.screenshot(path=str(OUTPUT_DIR / "jobs-page.png"), full_page=True)

99
tests/conftest.py

@ -1,5 +1,9 @@
from pathlib import Path
import base64
import shutil
import threading
import xml.etree.ElementTree as ET
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import pytest
from alembic import command
@ -48,6 +52,7 @@ async def test_app(
monkeypatch.setenv("IRON_CACHE_DIR", runtime_paths["cache_dir"])
monkeypatch.setenv("IRON_BOOTSTRAP_USERNAME", "admin")
monkeypatch.setenv("IRON_BOOTSTRAP_PASSWORD", "changeme-iron")
monkeypatch.setenv("IRON_SECRET_KEY", "test-secret-key")
get_settings.cache_clear()
create_engine.cache_clear()
@ -141,3 +146,95 @@ def fake_s3(monkeypatch: pytest.MonkeyPatch, runtime_paths: dict[str, str]):
monkeypatch.setattr(S3StorageAdapter, "download_file", _download_file)
monkeypatch.setattr(S3StorageAdapter, "object_exists", _object_exists)
return base
@pytest.fixture
def fake_webdav():
files: dict[str, bytes] = {}
directories = {"", "iron"}
auth_header = "Basic " + base64.b64encode(b"iron:secret-password").decode("ascii")
class Handler(BaseHTTPRequestHandler):
def log_message(self, format, *args): # type: ignore[override]
return
def _require_auth(self) -> bool:
if self.headers.get("Authorization") == auth_header:
return True
self.send_response(401)
self.end_headers()
return False
def _relative_path(self) -> str:
return self.path.removeprefix("/dav").strip("/")
def do_PROPFIND(self): # type: ignore[override]
if not self._require_auth():
return
relative_path = self._relative_path()
if relative_path not in directories and relative_path not in files:
self.send_response(404)
self.end_headers()
return
multistatus = ET.Element("{DAV:}multistatus")
response = ET.SubElement(multistatus, "{DAV:}response")
href = ET.SubElement(response, "{DAV:}href")
href.text = f"/dav/{relative_path}" if relative_path else "/dav/"
body = ET.tostring(multistatus, encoding="utf-8")
self.send_response(207)
self.send_header("Content-Type", "application/xml")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_MKCOL(self): # type: ignore[override]
if not self._require_auth():
return
relative_path = self._relative_path()
directories.add(relative_path)
self.send_response(201)
self.end_headers()
def do_PUT(self): # type: ignore[override]
if not self._require_auth():
return
relative_path = self._relative_path()
length = int(self.headers.get("Content-Length", "0"))
files[relative_path] = self.rfile.read(length)
parent = "/".join(relative_path.split("/")[:-1])
directories.add(parent)
self.send_response(201)
self.send_header("ETag", "fake-webdav-etag")
self.end_headers()
def do_GET(self): # type: ignore[override]
if not self._require_auth():
return
relative_path = self._relative_path()
if relative_path not in files:
self.send_response(404)
self.end_headers()
return
body = files[relative_path]
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_HEAD(self): # type: ignore[override]
if not self._require_auth():
return
relative_path = self._relative_path()
if relative_path in files:
self.send_response(200)
else:
self.send_response(404)
self.end_headers()
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
base_url = f"http://127.0.0.1:{server.server_address[1]}/dav"
yield {"endpoint_url": base_url, "username": "iron", "password": "secret-password", "files": files, "directories": directories}
server.shutdown()
server.server_close()

203
tests/test_backend_routes.py

@ -1,4 +1,8 @@
import pytest
from sqlalchemy import select
from app.core.secret_codec import decrypt_secret_config
from app.models.entities import Backend
@pytest.mark.anyio
@ -9,7 +13,7 @@ async def test_list_backends_returns_default_local_backend(api_client) -> None:
payload = response.json()
assert len(payload["items"]) >= 1
assert payload["items"][0]["name"] == "local-default"
assert payload["items"][0]["type"] == "local"
assert payload["items"][0]["type"] == "local_directory"
@pytest.mark.anyio
@ -18,7 +22,7 @@ async def test_create_local_backend(api_client, runtime_paths) -> None:
"/api/backends",
json={
"name": "archive-local",
"type": "local",
"type": "local_directory",
"stability_class": "stable",
"read_priority": 50,
"write_priority": 50,
@ -31,11 +35,11 @@ async def test_create_local_backend(api_client, runtime_paths) -> None:
assert response.status_code == 200
payload = response.json()["backend"]
assert payload["name"] == "archive-local"
assert payload["type"] == "local"
assert payload["type"] == "local_directory"
@pytest.mark.anyio
async def test_create_s3_backend_metadata(api_client) -> None:
async def test_create_s3_backend_metadata(api_client, session_factory) -> None:
response = await api_client.post(
"/api/backends",
json={
@ -47,6 +51,8 @@ async def test_create_s3_backend_metadata(api_client) -> None:
"config": {
"bucket": "iron-test",
"region": "us-east-1",
"access_key_id": "access-key",
"secret_access_key": "secret-key",
},
},
)
@ -55,6 +61,47 @@ async def test_create_s3_backend_metadata(api_client) -> None:
payload = response.json()["backend"]
assert payload["name"] == "s3-main"
assert payload["type"] == "s3"
assert payload["config"]["bucket"] == "iron-test"
assert "access_key_id" not in payload["config"]
assert "secret_access_key" not in payload["config"]
assert payload["secret_fields"] == ["access_key_id", "secret_access_key"]
async with session_factory() as session:
backend = (
await session.execute(select(Backend).where(Backend.name == "s3-main"))
).scalar_one()
assert backend.secret_config_json.startswith("fernet:v1:")
assert "access-key" not in backend.secret_config_json
assert "secret-key" not in backend.secret_config_json
@pytest.mark.anyio
async def test_create_webdav_backend_separates_password(api_client) -> None:
response = await api_client.post(
"/api/backends",
json={
"name": "openlist-main",
"type": "webdav",
"stability_class": "stable",
"read_priority": 60,
"write_priority": 40,
"config": {
"endpoint_url": "http://127.0.0.1:5244/dav",
"username": "iron",
"password": "secret-password",
"root_path": "apps/iron",
},
},
)
assert response.status_code == 200
payload = response.json()["backend"]
assert payload["type"] == "webdav"
assert payload["config"]["endpoint_url"] == "http://127.0.0.1:5244/dav"
assert payload["config"]["username"] == "iron"
assert payload["config"]["root_path"] == "apps/iron"
assert "password" not in payload["config"]
assert payload["secret_fields"] == ["password"]
@pytest.mark.anyio
@ -97,7 +144,7 @@ async def test_disable_backend(api_client) -> None:
"/api/backends",
json={
"name": "archive-local",
"type": "local",
"type": "local_directory",
"stability_class": "stable",
"read_priority": 50,
"write_priority": 50,
@ -114,6 +161,152 @@ async def test_disable_backend(api_client) -> None:
assert response.json()["backend"]["is_enabled"] is False
@pytest.mark.anyio
async def test_enable_backend(api_client) -> None:
create_response = await api_client.post(
"/api/backends",
json={
"name": "archive-local",
"type": "local_directory",
"stability_class": "stable",
"read_priority": 50,
"write_priority": 50,
"config": {
"base_path": "/tmp/archive-local",
},
},
)
backend_id = create_response.json()["backend"]["id"]
await api_client.post(f"/api/backends/{backend_id}/disable")
response = await api_client.post(f"/api/backends/{backend_id}/enable")
assert response.status_code == 200
assert response.json()["backend"]["is_enabled"] is True
@pytest.mark.anyio
async def test_update_s3_backend_preserves_and_replaces_secrets(api_client, session_factory) -> None:
create_response = await api_client.post(
"/api/backends",
json={
"name": "s3-main",
"type": "s3",
"stability_class": "stable",
"read_priority": 80,
"write_priority": 80,
"config": {
"bucket": "iron-test",
"region": "us-east-1",
"access_key_id": "access-key",
"secret_access_key": "secret-key",
},
},
)
backend_id = create_response.json()["backend"]["id"]
update_response = await api_client.patch(
f"/api/backends/{backend_id}",
json={
"name": "s3-archive",
"read_priority": 40,
"write_priority": 45,
"config": {
"bucket": "iron-archive",
"prefix": "cold",
},
},
)
assert update_response.status_code == 200
payload = update_response.json()["backend"]
assert payload["name"] == "s3-archive"
assert payload["read_priority"] == 40
assert payload["write_priority"] == 45
assert payload["config"]["bucket"] == "iron-archive"
assert payload["config"]["prefix"] == "cold"
assert payload["secret_fields"] == ["access_key_id", "secret_access_key"]
async with session_factory() as session:
backend = await session.get(Backend, backend_id)
assert decrypt_secret_config(backend.secret_config_json)["secret_access_key"] == "secret-key"
replace_response = await api_client.patch(
f"/api/backends/{backend_id}",
json={
"config": {
"secret_access_key": "next-secret-key",
},
},
)
assert replace_response.status_code == 200
async with session_factory() as session:
backend = await session.get(Backend, backend_id)
assert decrypt_secret_config(backend.secret_config_json)["secret_access_key"] == "next-secret-key"
@pytest.mark.anyio
async def test_delete_backend_requires_disabled_and_unreferenced(api_client) -> None:
create_response = await api_client.post(
"/api/backends",
json={
"name": "archive-local",
"type": "local_directory",
"stability_class": "stable",
"read_priority": 50,
"write_priority": 50,
"config": {
"base_path": "/tmp/archive-local",
},
},
)
backend_id = create_response.json()["backend"]["id"]
enabled_delete = await api_client.delete(f"/api/backends/{backend_id}")
assert enabled_delete.status_code == 409
assert enabled_delete.json()["error"]["code"] == "backend_delete_conflict"
disable_response = await api_client.post(f"/api/backends/{backend_id}/disable")
assert disable_response.status_code == 200
delete_response = await api_client.delete(f"/api/backends/{backend_id}")
assert delete_response.status_code == 204
@pytest.mark.anyio
async def test_delete_backend_rejects_policy_reference(api_client) -> None:
create_response = await api_client.post(
"/api/backends",
json={
"name": "s3-main",
"type": "s3",
"stability_class": "stable",
"read_priority": 80,
"write_priority": 80,
"config": {"bucket": "iron-test"},
},
)
backend_id = create_response.json()["backend"]["id"]
await api_client.put(
"/api/policies/placement/document",
json={
"require_local": True,
"stable_replica_count": 1,
"opportunistic_replica_count": 0,
"preferred_backend_ids": [backend_id],
"excluded_backend_ids": [],
"max_non_local_size_bytes": None,
},
)
await api_client.post(f"/api/backends/{backend_id}/disable")
delete_response = await api_client.delete(f"/api/backends/{backend_id}")
assert delete_response.status_code == 409
assert delete_response.json()["error"]["code"] == "backend_delete_conflict"
@pytest.mark.anyio
async def test_create_backend_rejects_invalid_config(api_client) -> None:
response = await api_client.post(

2
tests/test_config.py

@ -7,4 +7,4 @@ def test_settings_defaults() -> None:
assert settings.app_name == "Iron Gateway"
assert settings.api_prefix == "/api"
assert settings.database_url.startswith("sqlite+aiosqlite://")
assert settings.secret_key

13
tests/test_file_routes.py

@ -14,6 +14,11 @@ async def test_get_file_detail(api_client, create_file_metadata) -> None:
assert payload["mime_type"] == "video/mp4"
assert payload["size_bytes"] == 1048576
assert response.json()["preview_artifacts"] == []
assert response.json()["replicas"] == []
assert response.json()["placement_file_class"] == "video"
assert [item["id"] for item in response.json()["desired_backends"]] == ["bkd_local_default"]
assert response.json()["ready_replica_backend_ids"] == []
assert response.json()["missing_backend_ids"] == ["bkd_local_default"]
@pytest.mark.anyio
@ -59,6 +64,14 @@ async def test_download_file_returns_local_persisted_content(api_client) -> None
assert download_response.content == b"hello world"
assert download_response.headers["content-type"].startswith("video/mp4")
detail_response = await api_client.get(f"/api/files/{file_id}")
replica = detail_response.json()["replicas"][0]
assert replica["backend_id"] == "bkd_local_default"
assert replica["backend_type"] == "local_directory"
assert replica["status"] == "ready"
assert detail_response.json()["ready_replica_backend_ids"] == ["bkd_local_default"]
assert detail_response.json()["missing_backend_ids"] == []
@pytest.mark.anyio
async def test_download_file_returns_404_for_missing_file(api_client) -> None:

55
tests/test_job_routes.py

@ -191,6 +191,61 @@ async def test_enqueue_health_checks_creates_jobs(api_client, fake_s3) -> None:
assert run_response.json()["completed"] >= 2
@pytest.mark.anyio
async def test_webdav_replica_can_be_fetched_when_local_copy_is_missing(
api_client,
runtime_paths,
fake_webdav,
) -> None:
await api_client.post(
"/api/backends",
json={
"name": "openlist-main",
"type": "webdav",
"stability_class": "stable",
"read_priority": 90,
"write_priority": 90,
"config": {
"endpoint_url": fake_webdav["endpoint_url"],
"username": fake_webdav["username"],
"password": fake_webdav["password"],
"root_path": "iron",
},
},
)
create_response = await api_client.post(
"/files",
headers={"Tus-Resumable": "1.0.0", "Upload-Length": "12"},
)
upload_id = create_response.headers["Location"].rsplit("/", 1)[-1]
await api_client.patch(
f"/files/{upload_id}",
headers={
"Tus-Resumable": "1.0.0",
"Upload-Offset": "0",
"Content-Type": "application/offset+octet-stream",
},
content=b"hello webdav",
)
finalize_response = await api_client.post(
f"/api/uploads/{upload_id}/finalize",
json={"directory_id": "dir_root", "filename": "movie.mp4"},
)
file_id = finalize_response.json()["file"]["id"]
await api_client.post("/api/jobs/run-pending")
local_storage_dir = Path(runtime_paths["local_storage_dir"])
for item in local_storage_dir.rglob("*"):
if item.is_file():
item.unlink()
download_response = await api_client.get(f"/api/files/{file_id}/download")
assert download_response.status_code == 200
assert download_response.content == b"hello webdav"
@pytest.mark.anyio
async def test_enqueue_full_reconcile_creates_system_job(api_client) -> None:
response = await api_client.post("/api/jobs/enqueue-full-reconcile")

2
tests/test_migrations.py

@ -20,6 +20,8 @@ def test_alembic_upgrade_creates_core_tables(tmp_path: Path) -> None:
assert "users" in table_names
assert "directories" in table_names
assert "auth_sessions" in table_names
assert "placement_policies" in table_names
assert "upload_sessions" in table_names
assert "blob_replicas" in table_names
assert "alembic_version" in table_names

7
tests/test_policy_routes.py

@ -66,6 +66,11 @@ async def test_update_policy_and_preview_decision(api_client) -> None:
)
assert update_response.status_code == 200
assert update_response.json()["policy"]["preferred_backend_ids"] == [opportunistic_id, stable_b_id]
assert update_response.json()["reconcile_enqueued_jobs"] == 1
jobs_response = await api_client.get("/api/jobs")
assert jobs_response.status_code == 200
assert any(item["kind"] == "reconcile_all_files" for item in jobs_response.json()["items"])
preview_response = await api_client.get(
"/api/policies/placement/preview",
@ -88,7 +93,7 @@ async def test_update_policy_and_preview_decision(api_client) -> None:
{
"id": "bkd_local_default",
"name": "local-default",
"type": "local",
"type": "local_directory",
"stability_class": "local",
"read_priority": 100,
"write_priority": 100,

56
tests/test_storage_layout.py

@ -0,0 +1,56 @@
from datetime import datetime
from app.core.storage_layout import build_blob_storage_key
from app.core.storage_layout import build_export_snapshot_storage_key
from app.core.storage_layout import build_manifest_storage_key
from app.core.storage_layout import build_preview_artifact_storage_key
from app.models.entities import Blob
def test_build_blob_storage_key_uses_structured_object_layout() -> None:
blob = Blob(
id="blb_test",
file_version_id="ver_test",
blob_index=0,
kind="file",
content_hash="7335cc7645d24642dd0ff7873f55163d39853da4a747f8c630b950e51018423e",
size_bytes=11,
logical_offset=0,
)
assert build_blob_storage_key(blob) == (
"objects/file/sha256/73/35/"
"7335cc7645d24642dd0ff7873f55163d39853da4a747f8c630b950e51018423e.blob"
)
def test_build_preview_artifact_storage_key_uses_preview_namespace() -> None:
blob = Blob(
id="blb_preview",
file_version_id="ver_preview",
blob_index=0,
kind="file",
content_hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
size_bytes=128,
logical_offset=0,
)
assert build_preview_artifact_storage_key(artifact_type="inline_preview", blob=blob) == (
"objects/preview/inline_preview/sha256/aa/aa/"
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.blob"
)
def test_build_export_snapshot_storage_key_uses_date_partitions() -> None:
exported_at = datetime(2026, 4, 17, 15, 30, 45)
assert build_export_snapshot_storage_key(
exported_at=exported_at,
digest="Snapshot:Primary",
) == "objects/export/metadata/2026/04/17/snapshot-primary.json"
def test_build_manifest_storage_key_normalizes_scope_and_identifier() -> None:
assert build_manifest_storage_key(
scope="placement policy",
identifier="Other/Primary",
) == "objects/manifest/placement-policy/other-primary.json"

2
tests/test_upload_routes.py

@ -67,6 +67,8 @@ async def test_tus_create_head_patch_and_finalize(api_client, session_factory, r
).scalar_one()
replica = (await session.execute(select(BlobReplica).where(BlobReplica.backend_id == backend.id))).scalar_one()
assert replica.storage_key.startswith("objects/file/sha256/")
assert replica.storage_key.endswith(".blob")
stored_path = Path(runtime_paths["local_storage_dir"]) / replica.storage_key
assert stored_path.exists()
assert stored_path.read_bytes() == b"hello world"

63
tests/test_webdav_adapter.py

@ -0,0 +1,63 @@
from pathlib import Path
import pytest
from app.adapters.storage.webdav import WebDAVStorageAdapter
@pytest.mark.anyio
async def test_webdav_adapter_supports_check_upload_download_and_exists(
tmp_path: Path,
fake_webdav: dict[str, object],
) -> None:
adapter = WebDAVStorageAdapter(
{
"endpoint_url": fake_webdav["endpoint_url"],
"username": fake_webdav["username"],
"password": fake_webdav["password"],
"root_path": "iron",
}
)
check_result = await adapter.check()
assert check_result["status"] == "healthy"
assert check_result["detail"] == "iron"
source = tmp_path / "sample.txt"
source.write_text("openlist-webdav", encoding="utf-8")
put_result = await adapter.put_file(str(source), "blobs/ab/sample.txt")
assert put_result["storage_key"] == "blobs/ab/sample.txt"
assert await adapter.object_exists("blobs/ab/sample.txt") is True
assert await adapter.object_exists("blobs/ab/missing.txt") is False
target = tmp_path / "downloaded.txt"
result_path = await adapter.download_file("blobs/ab/sample.txt", target)
assert result_path.read_text(encoding="utf-8") == "openlist-webdav"
@pytest.mark.anyio
async def test_backend_route_can_check_webdav(api_client, fake_webdav: dict[str, object]) -> None:
create_response = await api_client.post(
"/api/backends",
json={
"name": "openlist-main",
"type": "webdav",
"stability_class": "stable",
"read_priority": 65,
"write_priority": 65,
"config": {
"endpoint_url": fake_webdav["endpoint_url"],
"username": fake_webdav["username"],
"password": fake_webdav["password"],
"root_path": "iron",
},
},
)
assert create_response.status_code == 200
backend_id = create_response.json()["backend"]["id"]
check_response = await api_client.post(f"/api/backends/{backend_id}/check")
assert check_response.status_code == 200
assert check_response.json()["status"] == "healthy"
assert check_response.json()["detail"] == "iron"
Loading…
Cancel
Save