69 changed files with 4254 additions and 240 deletions
@ -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) |
|||
@ -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") |
|||
@ -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") |
|||
@ -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: |
|||
... |
|||
@ -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}") |
|||
@ -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 |
|||
@ -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} |
|||
@ -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) |
|||
@ -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("-_") |
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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 |
|||
@ -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" |
|||
@ -0,0 +1,3 @@ |
|||
OPENLIST_BIND_HOST=127.0.0.1 |
|||
OPENLIST_PORT=5244 |
|||
OPENLIST_DATA_DIR=/srv/iron/openlist |
|||
@ -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" |
|||
@ -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 |
|||
|
After Width: | Height: | Size: 933 KiB |
@ -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) |
|||
@ -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; |
|||
} |
|||
@ -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()) |
|||
@ -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()) |
|||
@ -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()) |
|||
@ -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()) |
|||
@ -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()) |
|||
@ -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()) |
|||
@ -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" |
|||
@ -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…
Reference in new issue