From 1252c5b115e24d10766b6d3c045b7d851441ac86 Mon Sep 17 00:00:00 2001 From: Matteo Benedetto Date: Sat, 7 Mar 2026 13:20:19 +0100 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20Cellar=20MVP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 11 ++ .gitignore | 37 ++++ Dockerfile | 17 ++ README.md | 406 ++++++++++++++++++++++++++++++++++++++++++++ app/__init__.py | 0 app/config.py | 10 ++ app/database.py | 18 ++ app/main.py | 114 +++++++++++++ app/models.py | 25 +++ app/schemas.py | 28 +++ app/storage.py | 46 +++++ client.py | 412 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 15 ++ requirements.txt | 4 + 14 files changed, 1143 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/models.py create mode 100644 app/schemas.py create mode 100644 app/storage.py create mode 100644 client.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..157e9eb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.env +.venv/ +venv/ +.git/ +.gitignore +data/ +storage/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72cc1e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Runtime data — never commit +data/ +storage/ + +# Python bytecode +__pycache__/ +*.pyc +*.pyo +*.pyd + +# Environment files +.env +.env.* + +# Virtual environments +.venv/ +venv/ +env/ + +# Package build artifacts +*.egg-info/ +dist/ +build/ + +# Test caches +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Editor metadata +.idea/ +.vscode/ +*.swp +*~ + +# macOS +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..136ce24 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +RUN mkdir -p /app/data /app/storage + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..acf8905 --- /dev/null +++ b/README.md @@ -0,0 +1,406 @@ +# Cellar + +**Author:** Matteo Benedetto + +`Cellar` is a minimal self-hosted prototype for archiving exported [Bottles](https://usebottles.com/) environments and restoring them on another Linux system. + +The name refers to a wine cellar — the place where bottles are stored and preserved. + +The project currently provides: + +- a `FastAPI` server +- a local `SQLite` database for archive metadata +- local file storage for uploaded bottle backups +- a minimal Python CLI client +- a `Docker Compose` setup for running the server quickly + +The current goal is simple: make it possible to back up a local Bottles bottle, upload it to a Cellar server, list what is stored, and reinstall a bottle by name. + +--- + +## Current status +eventi ora è no c'è una cosa comune cinque +This is an early MVP. + +Implemented features: + +- health endpoint +- archive upload +- archive listing +- archive detail lookup +- archive download +- archive deletion +- local Bottles scan from the client +- interactive backup + upload wizard from the client +- install a bottle from the server with a simple command: + - `python client.py install ` + +Not implemented yet: + +- authentication +- permissions / multi-user separation +- editing metadata already stored in the database +- archive versioning +- deduplication +- tags normalization +- server-side search/filtering +- direct Bottles GUI integration + +--- + +## Project structure + +- [Dockerfile](Dockerfile) — container image for the API +- [docker-compose.yml](docker-compose.yml) — local deployment +- [requirements.txt](requirements.txt) — Python dependencies +- [client.py](client.py) — minimal CLI client +- [app/main.py](app/main.py) — API routes +- [app/models.py](app/models.py) — SQLAlchemy model +- [app/schemas.py](app/schemas.py) — API response schemas +- [app/database.py](app/database.py) — database setup +- [app/storage.py](app/storage.py) — file storage helpers +- [app/config.py](app/config.py) — environment/config handling +- [data/](data/) — SQLite database location +- [storage/](storage/) — uploaded archive files + +--- + +## Architecture + +### Server +The server is built with `FastAPI` and stores metadata in `SQLite`. + +Each uploaded archive creates: + +1. a physical file in [storage/](storage/) +2. a metadata record in the SQLite database in [data/](data/) + +Stored metadata includes: + +- archive display name +- bottle name +- description +- tags +- architecture +- runner +- Windows version +- original file name +- stored file name +- content type +- file size +- SHA256 digest +- creation time + +### Client +The client is intentionally minimal and uses the Python standard library. + +It can: + +- list archives on the server +- upload an existing archive file +- download an archive by id +- scan local Bottles bottles +- run a wizard to choose a local bottle, create a backup, and upload it +- install a remote bottle by name + +### Storage format +The current client creates a `.tar.gz` backup of the selected bottle directory. + +The install flow: + +1. finds the archive by bottle name +2. downloads it +3. extracts it safely +4. places it into the local Bottles directory + +Because Bottles backups contain `dosdevices` symlinks, extraction uses a trusted tar extraction mode while still enforcing a path traversal safety check. + +--- + +## Requirements + +Recommended local environment: + +- Docker +- Docker Compose +- Python 3.12+ +- Bottles installed locally if you want to use `scan-local`, `wizard-upload`, or `install` + +Expected Bottles path used by the client by default: + +- `~/.var/app/com.usebottles.bottles/data/bottles/bottles` + +This matches the Flatpak Bottles installation layout. + +--- + +## Running the server + +From the project root run: + +`docker compose up --build` + +The API is exposed on: + +- `http://127.0.0.1:8080` + +Interactive API docs are available at: + +- `http://127.0.0.1:8080/docs` + +--- + +## Environment configuration + +Current environment variables used by the API container: + +- `DATABASE_URL` +- `STORAGE_DIR` + +Current defaults in [docker-compose.yml](docker-compose.yml): + +- database: `sqlite:////app/data/bottle_archive.db` +- storage: `/app/storage` + +Host directories are mounted as volumes: + +- [data/](data/) → `/app/data` +- [storage/](storage/) → `/app/storage` + +--- + +## API endpoints + +### `GET /health` +Simple health check. + +### `GET /archives` +Returns the list of archives ordered by creation time descending. + +### `POST /archives` +Uploads a new archive file and stores metadata. + +Form fields supported: + +- `file` +- `name` +- `bottle_name` +- `description` +- `tags` +- `arch` +- `runner` +- `windows_version` + +### `GET /archives/{archive_id}` +Returns one archive metadata entry. + +### `GET /archives/{archive_id}/download` +Downloads the archive file. + +### `DELETE /archives/{archive_id}` +Deletes the archive file and the corresponding metadata row. + +--- + +## Client commands + +All commands below assume you are inside the project root. + +### List remote archives +`python client.py list` + +### Upload an existing archive file +`python client.py upload /path/to/archive.tar.gz --name "Empire Earth Gold" --bottle-name "Empire-Earth-Gold"` + +Optional extra metadata: + +`python client.py upload /path/to/archive.tar.gz --name "Empire Earth Gold" --bottle-name "Empire-Earth-Gold" --description "Backup from desktop" --tags "games,gog" --arch win32 --runner soda-9.0-1 --windows-version win7` + +### Download an archive by id +`python client.py download 1 ./downloads/` + +### Scan local Bottles bottles +`python client.py scan-local` + +JSON output: + +`python client.py scan-local --json` + +### Interactive wizard: choose local bottle, back it up, upload it +`python client.py wizard-upload` + +### Install a bottle from the server by name +`python client.py install Empire-Earth-Gold` + +Replace existing local bottle: + +`python client.py install Empire-Earth-Gold --replace` + +Use a custom Bottles directory: + +`python client.py install Empire-Earth-Gold --bottles-dir /custom/path` + +--- + +## Example workflow + +### Backup and upload +1. Start the server: + - `docker compose up --build` +2. Scan local Bottles: + - `python client.py scan-local` +3. Start the wizard: + - `python client.py wizard-upload` +4. Choose one of the local bottles +5. Confirm archive metadata +6. The client creates a temporary `.tar.gz` backup and uploads it + +### Restore on another machine +1. Start the same server or connect to a reachable one +2. Install the bottle by name: + - `python client.py install Empire-Earth-Gold` +3. Check local Bottles: + - `python client.py scan-local` + +--- + +## Known limitations + +### 1. No authentication +At the moment, **anyone who can reach the API can list, download, upload, and delete archives**. + +This is acceptable only for a local prototype or a trusted LAN environment. + +### 2. No metadata editing +Once an archive is inserted in the database, there is currently **no API or client command to update its metadata**. + +That means fields like: + +- `name` +- `description` +- `tags` +- `runner` +- `windows_version` + +cannot yet be corrected or refined without direct database manipulation or deleting and re-uploading the entry. + +### 3. No versions/history model +Multiple uploads of the same bottle are just separate rows. There is no version chain or “latest version” logic yet. + +### 4. Local storage only +The server stores everything on local disk. There is no S3/MinIO/remote object storage integration yet. + +### 5. Flatpak Bottles path assumption +The default client path targets a Flatpak Bottles installation. Native Bottles layouts may need `--bottles-dir`. + +--- + +## Next phases + +The next development phases should focus on two critical areas. + +### Phase 1: Authentication system integration +This is the most important missing feature. + +The project needs an authentication layer so the server can be used safely beyond a private local test. + +Recommended additions: + +- user accounts +- password hashing +- token-based authentication +- per-user archive ownership +- permission checks on download/delete/edit operations +- optional admin role + +Possible implementation direction: + +- FastAPI auth routes +- JWT access tokens or session tokens +- password hashing with a standard library such as `passlib`/`bcrypt` +- `users` table + relation from archive rows to owners + +Minimum target for this phase: + +- `POST /auth/register` +- `POST /auth/login` +- protected archive routes +- client support for login and storing a token locally + +### Phase 2: Database entry editing system +The second required feature is a proper metadata editing workflow. + +Right now archive metadata is write-once. That is too limiting. + +The project needs a system to edit database entries safely from the API and the client. + +Recommended additions: + +- `PATCH /archives/{id}` endpoint +- validation rules for editable fields +- audit fields like `updated_at` +- optional edit history +- client command such as: + - `python client.py edit 12 --name "..." --tags "..."` + +Editable fields should include at least: + +- `name` +- `bottle_name` +- `description` +- `tags` +- `arch` +- `runner` +- `windows_version` + +This phase is important because real archives often need cleanup after upload. + +--- + +## Recommended roadmap + +### Short term +- add authentication +- add metadata editing +- add archive search/filter endpoints +- add better error messages in client install/upload flows + +### Medium term +- add versioning for the same bottle +- add per-user archive namespaces +- add pagination on `GET /archives` +- add import/export manifest support +- add `upload-local ` non-interactive command + +### Long term +- replace local storage with S3/MinIO +- add a small web UI +- add full-text search on archive metadata +- add deduplication / retention policies +- integrate directly with Bottles workflows more tightly + +--- + +## Security note + +Until authentication is implemented, do **not** expose this API publicly. + +Use it only: + +- on localhost +- on a trusted home LAN +- behind an authenticated reverse proxy if temporarily needed + +--- + +## Development notes + +The project is currently intentionally simple. + +That simplicity is useful because it makes the next steps clear: + +1. secure access +2. allow metadata editing +3. improve archive lifecycle management + +Those two immediate next phases — **authentication** and **database entry editing** — should be considered required before treating this project as more than a local prototype. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..436fdf6 --- /dev/null +++ b/app/config.py @@ -0,0 +1,10 @@ +from pathlib import Path +import os + +BASE_DIR = Path(__file__).resolve().parent.parent +DATA_DIR = Path(os.getenv("DATA_DIR", BASE_DIR / "data")) +STORAGE_DIR = Path(os.getenv("STORAGE_DIR", BASE_DIR / "storage")) +DATABASE_URL = os.getenv( + "DATABASE_URL", + f"sqlite:///{(DATA_DIR / 'bottle_archive.db').as_posix()}", +) diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..d15af89 --- /dev/null +++ b/app/database.py @@ -0,0 +1,18 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker + +from app.config import DATABASE_URL + +connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {} + +engine = create_engine(DATABASE_URL, connect_args=connect_args) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..91d7b7a --- /dev/null +++ b/app/main.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager + +from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile, status +from fastapi.responses import FileResponse +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import DATA_DIR, STORAGE_DIR +from app.database import Base, engine, get_db +from app.models import Archive +from app.schemas import ArchiveRead +from app.storage import delete_file, ensure_storage_dirs, resolve_path, save_upload + + +@asynccontextmanager +async def lifespan(_: FastAPI): + DATA_DIR.mkdir(parents=True, exist_ok=True) + STORAGE_DIR.mkdir(parents=True, exist_ok=True) + Base.metadata.create_all(bind=engine) + ensure_storage_dirs() + yield + + +app = FastAPI( + title="Bottle Archive Server", + version="0.1.0", + description="Local archive server for exported Bottles backups.", + lifespan=lifespan, +) + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.get("/archives", response_model=list[ArchiveRead]) +def list_archives(db: Session = Depends(get_db)) -> list[Archive]: + return list(db.scalars(select(Archive).order_by(Archive.created_at.desc()))) + + +@app.post("/archives", response_model=ArchiveRead, status_code=status.HTTP_201_CREATED) +async def create_archive( + file: UploadFile = File(...), + name: str = Form(...), + bottle_name: str | None = Form(default=None), + description: str | None = Form(default=None), + tags: str | None = Form(default=None), + arch: str | None = Form(default=None), + runner: str | None = Form(default=None), + windows_version: str | None = Form(default=None), + db: Session = Depends(get_db), +) -> Archive: + if not file.filename: + raise HTTPException(status_code=400, detail="Uploaded file must have a filename.") + + stored_name, size_bytes, digest = await save_upload(file) + + archive = Archive( + name=name, + bottle_name=bottle_name, + description=description, + tags=tags, + arch=arch, + runner=runner, + windows_version=windows_version, + file_name=file.filename, + stored_name=stored_name, + content_type=file.content_type, + size_bytes=size_bytes, + sha256=digest, + ) + db.add(archive) + db.commit() + db.refresh(archive) + return archive + + +@app.get("/archives/{archive_id}", response_model=ArchiveRead) +def get_archive(archive_id: int, db: Session = Depends(get_db)) -> Archive: + archive = db.get(Archive, archive_id) + if archive is None: + raise HTTPException(status_code=404, detail="Archive not found.") + return archive + + +@app.get("/archives/{archive_id}/download") +def download_archive(archive_id: int, db: Session = Depends(get_db)) -> FileResponse: + archive = db.get(Archive, archive_id) + if archive is None: + raise HTTPException(status_code=404, detail="Archive not found.") + + path = resolve_path(archive.stored_name) + if not path.exists(): + raise HTTPException(status_code=404, detail="Stored file not found.") + + return FileResponse( + path=path, + media_type=archive.content_type or "application/octet-stream", + filename=archive.file_name, + ) + + +@app.delete("/archives/{archive_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_archive(archive_id: int, db: Session = Depends(get_db)) -> None: + archive = db.get(Archive, archive_id) + if archive is None: + raise HTTPException(status_code=404, detail="Archive not found.") + + delete_file(archive.stored_name) + db.delete(archive) + db.commit() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..5320279 --- /dev/null +++ b/app/models.py @@ -0,0 +1,25 @@ +from sqlalchemy import DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class Archive(Base): + __tablename__ = "archives" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + bottle_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + tags: Mapped[str | None] = mapped_column(Text, nullable=True) + arch: Mapped[str | None] = mapped_column(String(32), nullable=True) + runner: Mapped[str | None] = mapped_column(String(128), nullable=True) + windows_version: Mapped[str | None] = mapped_column(String(32), nullable=True) + file_name: Mapped[str] = mapped_column(String(255), nullable=False) + stored_name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + content_type: Mapped[str | None] = mapped_column(String(255), nullable=True) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + created_at: Mapped[str] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..7651f1b --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,28 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class ArchiveBase(BaseModel): + name: str + bottle_name: str | None = None + description: str | None = None + tags: str | None = None + arch: str | None = None + runner: str | None = None + windows_version: str | None = None + + +class ArchiveCreate(ArchiveBase): + pass + + +class ArchiveRead(ArchiveBase): + model_config = ConfigDict(from_attributes=True) + + id: int + file_name: str + content_type: str | None = None + size_bytes: int + sha256: str + created_at: datetime diff --git a/app/storage.py b/app/storage.py new file mode 100644 index 0000000..e6532be --- /dev/null +++ b/app/storage.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from hashlib import sha256 +from pathlib import Path +from uuid import uuid4 + +from fastapi import UploadFile + +from app.config import STORAGE_DIR + +CHUNK_SIZE = 1024 * 1024 + + +def ensure_storage_dirs() -> None: + STORAGE_DIR.mkdir(parents=True, exist_ok=True) + + +async def save_upload(file: UploadFile) -> tuple[str, int, str]: + ensure_storage_dirs() + + suffix = Path(file.filename or "archive.tar.gz").suffixes + extension = "".join(suffix) if suffix else ".bin" + stored_name = f"{uuid4().hex}{extension}" + destination = STORAGE_DIR / stored_name + + digest = sha256() + size = 0 + + with destination.open("wb") as buffer: + while chunk := await file.read(CHUNK_SIZE): + size += len(chunk) + digest.update(chunk) + buffer.write(chunk) + + await file.close() + return stored_name, size, digest.hexdigest() + + +def delete_file(stored_name: str) -> None: + path = STORAGE_DIR / stored_name + if path.exists(): + path.unlink() + + +def resolve_path(stored_name: str) -> Path: + return STORAGE_DIR / stored_name diff --git a/client.py b/client.py new file mode 100644 index 0000000..930726b --- /dev/null +++ b/client.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import shutil +import sys +import tarfile +import tempfile +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime +from pathlib import Path +from typing import Any + +DEFAULT_SERVER = "http://127.0.0.1:8080" +DEFAULT_BOTTLES_DIR = Path.home() / ".var/app/com.usebottles.bottles/data/bottles/bottles" + + +def request_json(url: str, method: str = "GET", data: bytes | None = None, headers: dict[str, str] | None = None): + req = urllib.request.Request(url, data=data, method=method) + for key, value in (headers or {}).items(): + req.add_header(key, value) + with urllib.request.urlopen(req) as response: + return json.loads(response.read().decode("utf-8")) + + +def print_archives(server: str) -> int: + archives = request_json(f"{server}/archives") + if not archives: + print("No archives found.") + return 0 + + print(f"{'ID':<4} {'Name':<30} {'Bottle':<30} {'Arch':<8} {'Runner':<18} {'Size(MB)':>10}") + print("-" * 110) + for item in archives: + size_mb = item["size_bytes"] / (1024 * 1024) + print( + f"{item['id']:<4} " + f"{(item['name'] or '-')[:30]:<30} " + f"{(item.get('bottle_name') or '-')[:30]:<30} " + f"{(item.get('arch') or '-'):<8} " + f"{(item.get('runner') or '-')[:18]:<18} " + f"{size_mb:>10.2f}" + ) + return 0 + + +def upload_archive(server: str, file_path: Path, fields: dict[str, str]) -> int: + boundary = "----BottleArchiveBoundary7MA4YWxkTrZu0gW" + data = [] + + def add_field(field_name: str, value: str): + data.extend( + [ + f"--{boundary}\r\n".encode(), + f'Content-Disposition: form-data; name="{field_name}"\r\n\r\n'.encode(), + value.encode(), + b"\r\n", + ] + ) + + for key, value in fields.items(): + if value: + add_field(key, value) + + filename = file_path.name + mime = "application/gzip" if filename.endswith((".tar.gz", ".tgz", ".gz")) else "application/octet-stream" + data.extend( + [ + f"--{boundary}\r\n".encode(), + f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode(), + f"Content-Type: {mime}\r\n\r\n".encode(), + file_path.read_bytes(), + b"\r\n", + f"--{boundary}--\r\n".encode(), + ] + ) + + payload = b"".join(data) + archive = request_json( + f"{server}/archives", + method="POST", + data=payload, + headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, + ) + print(f"Uploaded archive #{archive['id']}: {archive['name']}") + return 0 + + +def download_archive(server: str, archive_id: int, output: Path) -> int: + url = f"{server}/archives/{archive_id}/download" + req = urllib.request.Request(url, method="GET") + with urllib.request.urlopen(req) as response: + if output.is_dir(): + filename = response.headers.get_filename() or f"archive-{archive_id}.bin" + destination = output / filename + else: + destination = output + destination.write_bytes(response.read()) + print(f"Downloaded to {destination}") + return 0 + + +def find_remote_archive(server: str, bottle_ref: str) -> dict[str, Any]: + archives = request_json(f"{server}/archives") + bottle_ref_lower = bottle_ref.lower() + + for item in archives: + if (item.get("bottle_name") or "").lower() == bottle_ref_lower: + return item + for item in archives: + if (item.get("name") or "").lower() == bottle_ref_lower: + return item + + raise FileNotFoundError(f"No remote archive found for '{bottle_ref}'") + + +def safe_extract_tar(archive_path: Path, target_dir: Path) -> None: + with tarfile.open(archive_path, "r:gz") as tar: + for member in tar.getmembers(): + member_path = (target_dir / member.name).resolve() + if not str(member_path).startswith(str(target_dir.resolve())): + raise ValueError("Unsafe archive path detected.") + tar.extractall(target_dir, filter="fully_trusted") + + +def install_archive(server: str, bottle_ref: str, bottles_dir: Path, replace: bool) -> int: + archive = find_remote_archive(server, bottle_ref) + bottle_name = archive.get("bottle_name") or archive.get("name") or bottle_ref + target_dir = bottles_dir / bottle_name + + if target_dir.exists(): + if not replace: + print( + f"Bottle already exists: {target_dir}\n" + "Use --replace to overwrite it.", + file=sys.stderr, + ) + return 1 + shutil.rmtree(target_dir) + + bottles_dir.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory(prefix="bottle-install-") as tmp_dir: + tmp_path = Path(tmp_dir) + download_path = tmp_path / f"archive-{archive['id']}.tar.gz" + download_archive(server, int(archive["id"]), download_path) + + extract_dir = tmp_path / "extract" + extract_dir.mkdir(parents=True, exist_ok=True) + safe_extract_tar(download_path, extract_dir) + + candidates = [path for path in extract_dir.iterdir() if path.is_dir()] + if not candidates: + raise FileNotFoundError("Archive did not contain a bottle directory.") + + source_dir = candidates[0] + bottle_yml = source_dir / "bottle.yml" + if not bottle_yml.exists(): + raise FileNotFoundError("Archive does not look like a valid bottle backup.") + + shutil.move(str(source_dir), str(target_dir)) + + print(f"Installed bottle '{bottle_name}' to {target_dir}") + return 0 + + +def parse_bottle_yml(bottle_yml: Path) -> dict[str, str]: + data: dict[str, str] = {} + wanted_keys = {"Name", "Arch", "Runner", "Environment", "Windows"} + + for line in bottle_yml.read_text(encoding="utf-8", errors="ignore").splitlines(): + if ":" not in line or line.startswith(" "): + continue + key, value = line.split(":", 1) + if key in wanted_keys: + data[key] = value.strip().strip("'") + + return data + + +def collect_local_bottles(bottles_dir: Path) -> list[dict[str, Any]]: + if not bottles_dir.exists(): + return [] + + bottles: list[dict[str, Any]] = [] + for directory in sorted(path for path in bottles_dir.iterdir() if path.is_dir()): + bottle_yml = directory / "bottle.yml" + if not bottle_yml.exists(): + continue + + metadata = parse_bottle_yml(bottle_yml) + bottles.append( + { + "name": metadata.get("Name", directory.name), + "directory": directory.name, + "path": str(directory), + "arch": metadata.get("Arch", "-"), + "runner": metadata.get("Runner", "-"), + "environment": metadata.get("Environment", "-"), + "windows": metadata.get("Windows", "-"), + } + ) + + return bottles + + +def scan_local_bottles(bottles_dir: Path, as_json: bool) -> int: + bottles = collect_local_bottles(bottles_dir) + if as_json: + print(json.dumps(bottles, indent=2)) + return 0 + + if not bottles: + print(f"No local bottles found in {bottles_dir}") + return 0 + + print(f"Local Bottles directory: {bottles_dir}") + print(f"{'Dir':<24} {'Name':<28} {'Arch':<8} {'Runner':<18} {'Env':<12} {'Windows':<10}") + print("-" * 110) + for bottle in bottles: + print( + f"{bottle['directory'][:24]:<24} " + f"{bottle['name'][:28]:<28} " + f"{bottle['arch']:<8} " + f"{bottle['runner'][:18]:<18} " + f"{bottle['environment'][:12]:<12} " + f"{bottle['windows'][:10]:<10}" + ) + return 0 + + +def create_bottle_backup(bottle: dict[str, Any], output_dir: Path | None = None) -> Path: + source_dir = Path(bottle["path"]) + target_dir = output_dir or Path(tempfile.gettempdir()) + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + archive_name = f"{bottle['directory']}-{timestamp}.tar.gz" + archive_path = target_dir / archive_name + + with tarfile.open(archive_path, "w:gz") as tar: + tar.add(source_dir, arcname=source_dir.name) + + return archive_path + + +def prompt_text(label: str, default: str) -> str: + value = input(f"{label} [{default}]: ").strip() + return value or default + + +def choose_bottle(bottles: list[dict[str, Any]]) -> dict[str, Any]: + print("Available local bottles:") + for index, bottle in enumerate(bottles, start=1): + print( + f" {index}. {bottle['directory']} " + f"(arch={bottle['arch']}, runner={bottle['runner']}, windows={bottle['windows']})" + ) + + while True: + raw = input("Select a bottle number: ").strip() + try: + choice = int(raw) + except ValueError: + print("Please enter a valid number.") + continue + + if 1 <= choice <= len(bottles): + return bottles[choice - 1] + + print("Choice out of range.") + + +def wizard_upload(server: str, bottles_dir: Path) -> int: + bottles = collect_local_bottles(bottles_dir) + if not bottles: + print(f"No local bottles found in {bottles_dir}") + return 0 + + bottle = choose_bottle(bottles) + display_name = prompt_text("Archive name", bottle["name"]) + bottle_name = prompt_text("Bottle name", bottle["directory"]) + description = prompt_text("Description", f"Backup of {bottle['name']}") + tags = prompt_text("Tags", "bottles,backup") + + print(f"\nCreating backup for {bottle['directory']}...") + archive_path = create_bottle_backup(bottle) + print(f"Backup created: {archive_path}") + + try: + return upload_archive( + server, + archive_path, + { + "name": display_name, + "bottle_name": bottle_name, + "description": description, + "tags": tags, + "arch": str(bottle.get("arch", "")), + "runner": str(bottle.get("runner", "")), + "windows_version": str(bottle.get("windows", "")), + }, + ) + finally: + archive_path.unlink(missing_ok=True) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Minimal client for Bottle Archive Server") + parser.add_argument("--server", default=DEFAULT_SERVER, help="Base server URL") + + subparsers = parser.add_subparsers(dest="command", required=True) + + subparsers.add_parser("list", help="List uploaded bottle archives") + + upload_parser = subparsers.add_parser("upload", help="Upload a bottle archive") + upload_parser.add_argument("file", type=Path, help="Path to the archive file") + upload_parser.add_argument("--name", required=True, help="Display name") + upload_parser.add_argument("--bottle-name", help="Bottle name") + upload_parser.add_argument("--description", help="Description") + upload_parser.add_argument("--tags", help="Tags") + upload_parser.add_argument("--arch", help="Architecture") + upload_parser.add_argument("--runner", help="Runner") + upload_parser.add_argument("--windows-version", help="Windows version") + + download_parser = subparsers.add_parser("download", help="Download an archive by id") + download_parser.add_argument("archive_id", type=int, help="Archive id") + download_parser.add_argument("output", type=Path, help="Output file or directory") + + install_parser = subparsers.add_parser( + "install", + help="Install a bottle from the server by bottle name or archive name", + ) + install_parser.add_argument("bottle", help="Bottle name or archive name to install") + install_parser.add_argument( + "--bottles-dir", + type=Path, + default=DEFAULT_BOTTLES_DIR, + help="Path to the local Bottles directory", + ) + install_parser.add_argument("--replace", action="store_true", help="Replace existing local bottle") + + scan_parser = subparsers.add_parser("scan-local", help="List bottles installed on this computer") + scan_parser.add_argument( + "--bottles-dir", + type=Path, + default=DEFAULT_BOTTLES_DIR, + help="Path to the local Bottles directory", + ) + scan_parser.add_argument("--json", action="store_true", help="Print results as JSON") + + wizard_parser = subparsers.add_parser( + "wizard-upload", + help="Choose a local bottle, create a backup, and upload it to the server", + ) + wizard_parser.add_argument( + "--bottles-dir", + type=Path, + default=DEFAULT_BOTTLES_DIR, + help="Path to the local Bottles directory", + ) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + server = args.server.rstrip("/") + + try: + if args.command == "list": + return print_archives(server) + if args.command == "upload": + return upload_archive( + server, + args.file, + { + "name": args.name, + "bottle_name": args.bottle_name or "", + "description": args.description or "", + "tags": args.tags or "", + "arch": args.arch or "", + "runner": args.runner or "", + "windows_version": args.windows_version or "", + }, + ) + if args.command == "download": + return download_archive(server, args.archive_id, args.output) + if args.command == "install": + return install_archive(server, args.bottle, args.bottles_dir, args.replace) + if args.command == "scan-local": + return scan_local_bottles(args.bottles_dir, args.json) + if args.command == "wizard-upload": + return wizard_upload(server, args.bottles_dir) + parser.error("Unknown command") + return 2 + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="ignore") + print(f"HTTP {exc.code}: {detail or exc.reason}", file=sys.stderr) + return 1 + except urllib.error.URLError as exc: + print(f"Connection error: {exc.reason}", file=sys.stderr) + return 1 + except FileNotFoundError as exc: + print(f"File error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fe74140 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + api: + build: + context: . + dockerfile: Dockerfile + container_name: bottle-archive-api + ports: + - "8080:8000" + environment: + DATABASE_URL: sqlite:////app/data/bottle_archive.db + STORAGE_DIR: /app/storage + volumes: + - ./data:/app/data + - ./storage:/app/storage + restart: unless-stopped diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ab461b1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +sqlalchemy>=2.0.0 +python-multipart>=0.0.9