Initial commit: Hermes Agent Skills collection
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
---
|
||||
name: seafile-api
|
||||
description: "Seafile Cloud administration: API interaction (token auth, repos, dirs, file ops) and Docker server administration (fsck, MySQL volume recovery, startup debugging, image-version-change pitfalls). Covers Seafile 12.x–13.x MC Docker edition."
|
||||
tags: [file-storage, cloud, api, seafile, docker, recovery]
|
||||
---
|
||||
|
||||
# Seafile Cloud Administration
|
||||
|
||||
This skill covers two domains:
|
||||
|
||||
1. **API Interaction** — token auth, repo/dir/file operations via REST API
|
||||
2. **Docker Server Administration** — `seaf-fsck`, MySQL volume recovery, startup debugging, selective restore from PBS backups
|
||||
|
||||
For Docker server administration, recovery procedures, and fsck troubleshooting, see `references/seafile-docker-recovery.md`.
|
||||
For backup strategy analysis (offsite/PBS/S3/native), see `references/seafile-immich-backup-strategy.md`.
|
||||
|
||||
## API: Authentication
|
||||
|
||||
Seafile uses token-based API auth. **Never use cookie/session login** — it doesn't work reliably.
|
||||
|
||||
```bash
|
||||
# Get API token
|
||||
curl -ks -H 'Content-Type: application/json' \
|
||||
-d '{"username":"user@example.com","password":"password"}' \
|
||||
"https://seafile.yourdomain.com/api2/auth-token/"
|
||||
# → {"token":"abc123..."}
|
||||
```
|
||||
|
||||
## API: List Repositories
|
||||
|
||||
```bash
|
||||
curl -ks -H "Authorization: Token {token}" \
|
||||
-H "X-Requested-With: XMLHttpRequest" \
|
||||
"https://seafile.yourdomain.com/api2/repos/"
|
||||
```
|
||||
|
||||
Returns array of repos with type (`"repo"` = personal, `"grepo"` = group-shared).
|
||||
|
||||
## API: List Directory Contents
|
||||
|
||||
```bash
|
||||
curl -ks -H "Authorization: Token {token}" \
|
||||
-H "X-Requested-With: XMLHttpRequest" \
|
||||
"https://seafile.yourdomain.com/api2/repos/{repo-id}/dir/?p=%2F"
|
||||
```
|
||||
|
||||
Returns array of items: `{type:"dir"/"file", name, size, mtime, id, permission}`.
|
||||
|
||||
## API: Download File
|
||||
|
||||
**Seafile 13.x (Pro/Plus) — file download via `/api2/repos/.../files/` returns 404.** This endpoint only works on older Seafile versions.
|
||||
|
||||
**Workarounds for Seafile 13.x:**
|
||||
1. Use `requests.Session()` to log in via the web UI (`/accounts/login/`) and navigate to the file page, then extract the download URL from the React SPA's network calls.
|
||||
2. Use WebDAV: `curl -k -u user:token https://server/dav/path/to/file`
|
||||
3. Use `seafile-cli` if available.
|
||||
4. Manually download via browser/CLI and copy to the server.
|
||||
|
||||
```bash
|
||||
# Old endpoint (ONLY works on Seafile < 13.x):
|
||||
curl -ks -H "Authorization: Token {token}" \
|
||||
-H "X-Requested-With: XMLHttpRequest" \
|
||||
"https://seafile.yourdomain.com/api2/repos/{repo-id}/files/?p=%2Fpath%2Fto%2Ffile.txt" \
|
||||
-o output.txt
|
||||
```
|
||||
|
||||
For login + download via Python `requests`:
|
||||
```python
|
||||
import requests, re, urllib.parse
|
||||
|
||||
API = "https://seafile.yourdomain.com"
|
||||
REPO = "repo-id-here"
|
||||
FILE = "/path/to/file.pdf"
|
||||
|
||||
session = requests.Session()
|
||||
session.headers.update({"User-Agent": "Mozilla/5.0 ..."})
|
||||
|
||||
# 1. Get CSRF token
|
||||
resp = session.get(API, allow_redirects=True, timeout=15)
|
||||
csrf = re.search(r'name="csrfmiddlewaretoken"[^>]*value="([^"]*)"', resp.text).group(1)
|
||||
|
||||
# 2. POST login
|
||||
session.post(resp.url, data={
|
||||
"login": "user@example.com", "password": "pw",
|
||||
"csrfmiddlewaretoken": csrf, "next": "/",
|
||||
}, allow_redirects=True, timeout=15)
|
||||
|
||||
# 3. Access file page — the SPA loads download via JS; inspect page source for download patterns
|
||||
file_url = f"{API}/library/{REPO}/docs/?p={urllib.parse.quote(FILE, safe='/')}"
|
||||
resp = session.get(file_url, timeout=15)
|
||||
# Look for 'download' patterns in resp.text — no static <a> tags exist
|
||||
```
|
||||
|
||||
## API: Create Directory
|
||||
|
||||
```bash
|
||||
curl -ks -X PUT -H "Authorization: Token {token}" \
|
||||
-H "X-Requested-With: XMLHttpRequest" \
|
||||
"https://seafile.yourdomain.com/api/v2.1/repos/{repo-id}/directory/" \
|
||||
--data-urlencode "path=/new_folder"
|
||||
```
|
||||
|
||||
## API: Upload File
|
||||
|
||||
**Seafile v2.1 Basic Edition (default) — ALL API upload endpoints return 404.**
|
||||
The following endpoints are ALL disabled:
|
||||
- `/api/v2.1/repos/{repo-id}/upload/`
|
||||
- `/api/v2.0/repos/{repo-id}/upload/`
|
||||
- `/api/v1/repos/{repo-id}/upload/`
|
||||
- `/api/repos/{repo-id}/upload/`
|
||||
- `/api/upload/`
|
||||
- `/api/v1/upload/`
|
||||
- `/seafhttp/upload/`
|
||||
|
||||
**Fallback: Playwright Web UI Automation**
|
||||
|
||||
The React web UI upload button uses selector `[class*="upload-file"]`. It sits in a dropdown, so use JS click + file chooser:
|
||||
|
||||
```python
|
||||
from playwright.sync_api import sync_playwright
|
||||
import json, subprocess, glob, time
|
||||
|
||||
SEAF_URL = "https://cloud.familie-schoen.com"
|
||||
repo_id = "737eb5b4-731e-4bfe-8a42-8edd62b04c6e" # from /api2/repos/
|
||||
TARGET_DIR = "/Amazon"
|
||||
|
||||
creds = json.loads(subprocess.run(
|
||||
'op item get "cloud.familie-schoen.com" --vault "Hermes" --format json',
|
||||
shell=True, capture_output=True, text=True).stdout)
|
||||
username, password = creds['fields'][0]['value'], creds['fields'][1]['value']
|
||||
|
||||
files = sorted(glob.glob("/tmp/amazon-invoices-2025/Amazon_Rechnung_*.pdf"))
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
context = browser.new_context(viewport={"width": 1280, "height": 800}, ignore_https_errors=True)
|
||||
page = context.new_page()
|
||||
|
||||
# Login
|
||||
page.goto(f"{SEAF_URL}/accounts/login/", wait_until="domcontentloaded", timeout=30000)
|
||||
page.fill('input[name="login"]', username)
|
||||
page.fill('input[name="password"]', password)
|
||||
page.click('button[type="submit"]')
|
||||
page.wait_for_url(lambda u: "login" not in u.lower(), timeout=10000)
|
||||
|
||||
# Navigate to target directory
|
||||
page.goto(f"{SEAF_URL}/library/{repo_id}/docs/?p=%2F{TARGET_DIR.lstrip('/')}", wait_until="domcontentloaded", timeout=30000)
|
||||
time.sleep(2)
|
||||
|
||||
# Trigger file chooser via JS click (button is in dropdown, not directly clickable)
|
||||
with page.expect_file_chooser() as fc:
|
||||
page.evaluate("""() => {
|
||||
document.querySelector('[class*="upload-file"]')?.click();
|
||||
}""")
|
||||
time.sleep(1)
|
||||
|
||||
file_chooser = fc.value
|
||||
file_chooser.set_files(files)
|
||||
time.sleep(20) # Wait for upload to complete
|
||||
browser.close()
|
||||
```
|
||||
|
||||
Key details:
|
||||
- Selector: `[class*="upload-file"]` — matches `.sf3-font-upload-files sf3-font mr-2 dropdown-item-icon`
|
||||
- Must use JS click (`page.evaluate`) because the button is in a hidden dropdown (visibility: hidden → click timeout)
|
||||
- Use `page.expect_file_chooser()` context manager to catch the file dialog
|
||||
- `file_chooser.set_files(files)` accepts a list for batch upload
|
||||
|
||||
## API: Delete File
|
||||
|
||||
```bash
|
||||
curl -ks -X DELETE -H "Authorization: Token {token}" \
|
||||
-H "X-Requested-With: XMLHttpRequest" \
|
||||
"https://seafile.yourdomain.com/api/v2.1/repos/{repo-id}/files/" \
|
||||
-d "p=/path/to/file"
|
||||
```
|
||||
|
||||
## Docker Server Administration
|
||||
|
||||
### Running seaf-fsck
|
||||
|
||||
```bash
|
||||
# Find the seafile-server version directory
|
||||
docker exec seafile bash -lc 'ls /opt/seafile/ | grep seafile-server'
|
||||
# → seafile-server-12.0.14
|
||||
|
||||
# Run fsck
|
||||
docker exec seafile bash -lc '/opt/seafile/seafile-server-12.0.14/seaf-fsck.sh'
|
||||
```
|
||||
|
||||
The fsck checks consistency between MySQL metadata (Repo table, commits) and the file block storage. If MySQL tables are missing or the `seafile` DB user doesn't exist, fsck will report warnings but exit cleanly without actually checking anything.
|
||||
|
||||
### fsck Silent Death (OOM Under Heavy I/O)
|
||||
|
||||
**Pitfall**: `seaf-fsck` can die silently under heavy I/O competition
|
||||
(e.g. concurrent Ceph recovery + RBD copy on the same OSDs). The process
|
||||
gets OOM-killed or I/O-starved, leaving no error in the fsck output log.
|
||||
The log simply stops at the last repo being checked, with no "finished"
|
||||
or error message.
|
||||
|
||||
**Detection**: Don't trust the log output alone — check if the process
|
||||
is still alive:
|
||||
```bash
|
||||
# Inside the CT:
|
||||
ps aux | grep seaf-fsck | grep -v grep | wc -l
|
||||
# 0 = process died, needs restart
|
||||
# 3+ = process running (multiple threads)
|
||||
```
|
||||
|
||||
**Restart**: Simply re-run the fsck command. Already-checked repos are
|
||||
processed quickly (seconds), so progress jumps forward rapidly after
|
||||
restart. Large photo repos ("Fotos", "Lightroom Bilder") are the usual
|
||||
stalling points — they have tens of thousands of commits and millions
|
||||
of file blocks.
|
||||
|
||||
**Large repos**: Repos with 24000-34000+ commits take hours per repo
|
||||
under normal I/O. Under I/O contention they may appear hung for 1+ hour
|
||||
per repo. The process is alive (state `Dl` = disk I/O wait) but barely
|
||||
progressing. Serializing I/O (pausing competing operations) dramatically
|
||||
speeds up fsck.
|
||||
|
||||
### Recovery from Lost MySQL Volume
|
||||
|
||||
See `references/seafile-docker-recovery.md` for the full debugging procedure covering:
|
||||
- `wait_for_mysql()` infinite loop diagnosis
|
||||
- `init_seafile_server()` skipping schema creation
|
||||
- Selective restore from PBS backup (MySQL volume + conf only, skip seafile-data)
|
||||
- Manual DB init via `docker run` setup script (Option B)
|
||||
- Manual DB/User recreation and SQL schema import (Option B2)
|
||||
- **Repo reconstruction from commit storage** (Option C) — when DBs are fresh but
|
||||
file blocks are intact, scan commit JSON objects to find HEAD commits and
|
||||
register repos in `Repo`/`Branch`/`RepoOwner`/`RepoInfo` tables. See
|
||||
`references/seafile-repo-reconstruction.md` for the full procedure.
|
||||
|
||||
## API Pitfalls
|
||||
|
||||
- **Wrong API paths** — `/api/v2.1/repos/{repo}/directory/` and `/api/v2.0/` return HTML error pages. Use `/api2/repos/{repo-id}/dir/` for listing.
|
||||
- **Missing header** — `X-Requested-With: XMLHttpRequest` is required for most API calls, or the server redirects to the login page.
|
||||
- **Cookie login does NOT work** — The AJAX form POST (`/seahub/ajax/login/`) renders an error page. Always use token auth.
|
||||
- **Web UI login returns 403 but sets cookie** — The Seafile login POST (`/accounts/login/`) may return HTTP 403 despite successfully setting the session cookie. This is expected on Seafile 13.x; subsequent requests to the same session work.
|
||||
- **URL encoding** — Paths must be URL-encoded for `p=` parameter (`space` → `%20`, `/` → `%2F`).
|
||||
- **Seafile 13.x (Pro/Plus)** — The `/api/v2.1/repos/{repo}/files/`, `/seafhttp/`, and WebDAV endpoints all return 404/400. Token auth works for repo listing (`/api2/repos/`) and dir listing (`/api2/repos/{repo}/dir/`), but file download is broken via API. Use web login + requests.Session() workaround or manual download.
|
||||
- **WebDAV disabled** — Seafile may have WebDAV disabled on newer versions. Check with `curl -k -o /dev/null -w '%{http_code}' -X PROPFIND "https://server/dav/"`.
|
||||
- **Auth'd 500 vs unauth'd 403** — If authenticated calls (`Authorization: Token ...`) return HTTP 500 (HTML "Page unavailable") while unauthenticated calls return HTTP 403 (valid JSON), the backend is in a broken state. Get a fresh token, and if all endpoints return 500, the server needs a restart.
|
||||
@@ -0,0 +1,498 @@
|
||||
# Seafile Docker Recovery: MySQL Volume Loss & Selective PBS Restore
|
||||
|
||||
## Scenario
|
||||
|
||||
Seafile MC Docker edition on CT 111 (n5pro, 10.0.20.91). The `.env` was changed
|
||||
(image downgraded from `seafile-mc:13.0-latest` to `seafile-mc:12.0-latest`,
|
||||
hostname changed from `cloud.familie-schoen.com` to `seafile.familie-schoen.com`),
|
||||
which triggered MySQL volume reinitialization — wiping all Seafile databases
|
||||
(`seafile_db`, `ccnet_db`, `seahub_db`) and the `seafile` MySQL user. The file
|
||||
block storage (`/opt/seafile-data/`) remained intact. A PBS backup of CT 111 from
|
||||
2026-07-02 existed on `noris_s3` storage. The original `/opt/seafile/` config
|
||||
directory was preserved as a ZIP by the user.
|
||||
|
||||
## Symptoms
|
||||
|
||||
### Symptom 1: Container "Up" but not actually serving
|
||||
|
||||
```bash
|
||||
docker ps # → seafile Up 7 hours
|
||||
docker logs seafile --tail 50 # → "waiting for mysql server to be ready: mysql is not ready" (repeating forever)
|
||||
```
|
||||
|
||||
The container's entrypoint (`/scripts/start.py`) calls `wait_for_mysql()` before
|
||||
proceeding to `init_seafile_server()`. If MySQL connection fails, it loops forever.
|
||||
The container stays "Up" (PID 1 alive) but Seafile never starts.
|
||||
|
||||
### Symptom 2: seaf-fsck warns about DB connection
|
||||
|
||||
```bash
|
||||
docker exec seafile bash -lc '/opt/seafile/seafile-server-12.0.14/seaf-fsck.sh'
|
||||
# → [WARNING] Failed to connect to MySQL: Access denied for user 'seafile'@'172.18.0.5'
|
||||
# → seaf-fsck run done
|
||||
# → Done.
|
||||
```
|
||||
|
||||
Fsck exits 0 but didn't actually check anything — the DB connection failed.
|
||||
|
||||
### Symptom 3: MySQL has no Seafile databases
|
||||
|
||||
```bash
|
||||
docker exec seafile-mysql mysql -uroot -p'PASSWORD' -e 'SHOW DATABASES;'
|
||||
# → information_schema, mysql, performance_schema, sys (NO seafile_db, ccnet_db, seahub_db)
|
||||
```
|
||||
|
||||
### Symptom 4: No seafile MySQL user
|
||||
|
||||
```bash
|
||||
docker exec seafile-mysql mysql -uroot -p'PASSWORD' \
|
||||
-e "SELECT user,host FROM mysql.user WHERE user LIKE '%sea%';"
|
||||
# → Empty set
|
||||
```
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### How `wait_for_mysql()` works
|
||||
|
||||
File: `/scripts/utils.py` inside the seafile container:
|
||||
|
||||
```python
|
||||
def wait_for_mysql():
|
||||
db_host = get_conf('DB_HOST', '127.0.0.1')
|
||||
db_user = 'root'
|
||||
db_passwd = get_conf('DB_ROOT_PASSWD', '')
|
||||
|
||||
# BUT: if seafile.conf exists, it overrides with the seafile user!
|
||||
seafile_conf_path = join(topdir, 'conf', 'seafile.conf')
|
||||
if exists(seafile_conf_path):
|
||||
cp = ConfigParser()
|
||||
cp.read(seafile_conf_path)
|
||||
db_host = cp.get('database', 'host') # → 'db'
|
||||
db_user = cp.get('database', 'user') # → 'seafile'
|
||||
db_passwd = cp.get('database', 'password')
|
||||
db_port = int(cp.get('database', 'port'))
|
||||
|
||||
while True:
|
||||
try:
|
||||
connection = pymysql.connect(host=db_host, port=db_port,
|
||||
user=db_user, passwd=db_passwd)
|
||||
except Exception as e:
|
||||
# prints "mysql is not ready" and sleeps
|
||||
```
|
||||
|
||||
**Critical insight**: Once `seafile.conf` exists (from a prior successful install),
|
||||
`wait_for_mysql()` uses the `seafile` user — NOT root. If the MySQL volume was
|
||||
wiped, the `seafile` user doesn't exist, and this loop runs forever.
|
||||
|
||||
### Why `init_seafile_server()` doesn't fix it
|
||||
|
||||
Even after manually recreating the `seafile` user and databases, restarting the
|
||||
container produces:
|
||||
|
||||
```
|
||||
Skip running setup-seafile-mysql.py because there is existing seafile-data folder.
|
||||
```
|
||||
|
||||
The bootstrap script (`/scripts/bootstrap.py`) skips DB initialization when
|
||||
`seafile-data/` already exists. So the MySQL tables are never created, and fsck
|
||||
reports `Table 'seafile_db.Repo' doesn't exist`.
|
||||
|
||||
## Recovery Procedure
|
||||
|
||||
### Option A: Full PBS Restore of MySQL Volume (Preferred)
|
||||
|
||||
When a PBS backup exists, restoring the entire MySQL data directory is cleanest
|
||||
because it includes all users, grants, schemas, and data.
|
||||
|
||||
⚠️ **Before attempting PBS restore, verify the backup is restorable** (learned
|
||||
the hard way 2026-07-06):
|
||||
|
||||
1. Check verification state in `pvesh get` output — if `{"state":"failed"}`,
|
||||
the backup has missing/corrupt chunks and may not restore.
|
||||
2. Check snapshot directory on PBS server for `.didx` vs `.tmp_didx` —
|
||||
only `.didx` snapshots are complete and restorable.
|
||||
3. Check for `.bad` chunk files on PBS server — if any chunk referenced by
|
||||
the backup is `.bad` (0 bytes), restore aborts immediately with no option
|
||||
to skip. Files dependent on that chunk are permanently lost.
|
||||
|
||||
See `proxmox-ve-administration` skill → `references/pbs-lxc-setup-2026-07.md`
|
||||
pitfalls #13–16 for PBS credential lookup, verification checking, corrupted
|
||||
chunk diagnosis, and incomplete backup detection.
|
||||
|
||||
If PBS restore fails due to corrupted chunks, fall back to Option B (manual
|
||||
recreation) and use `seaf-fsck.sh --repair` to rebuild repo metadata from
|
||||
the intact `/opt/seafile-data/` file blocks.
|
||||
|
||||
#### Step 1: Locate the PBS backup
|
||||
|
||||
```bash
|
||||
# On the PVE node hosting the CT (n5pro = 10.0.20.91):
|
||||
pvesh get /nodes/$(hostname)/storage/noris_s3/content --content-type backup 2>&1 \
|
||||
| awk -F'│' '{print $3}' \
|
||||
| grep 'ct/111'
|
||||
# → noris_s3:backup/ct/111/2026-07-02T21:00:00Z
|
||||
```
|
||||
|
||||
**Note**: `pvesh get` outputs a Unicode box-drawing table, not JSON. Parse with
|
||||
`awk -F'│'` (Unicode pipe character U+2502). Column 3 contains the volid.
|
||||
|
||||
#### Step 2: Stop Seafile containers
|
||||
|
||||
```bash
|
||||
# On the PVE node:
|
||||
pct exec 111 -- docker stop seafile seafile-mysql seafile-memcached
|
||||
```
|
||||
|
||||
#### Step 3: Rename broken MySQL volume
|
||||
|
||||
```bash
|
||||
pct exec 111 -- mv /opt/seafile-mysql/db /opt/seafile-mysql/db.broken-$(date +%Y%m%d)
|
||||
```
|
||||
|
||||
#### Step 4: Restore from PBS
|
||||
|
||||
Use `pct restore` with `--storage` pointing to the CT's root storage, then
|
||||
selectively extract only `/opt/seafile-mysql/db/` from the restored backup.
|
||||
|
||||
Alternatively, use `proxmox-backup-client` to restore specific files:
|
||||
|
||||
```bash
|
||||
# Map the PBS backup to a temp location
|
||||
# PBS restore maps the CT filesystem
|
||||
pct exec 111 -- bash -c '
|
||||
mkdir -p /tmp/restore
|
||||
# Use pb-client to restore just the mysql db path
|
||||
# (requires PBS credentials)
|
||||
'
|
||||
```
|
||||
|
||||
#### Step 5: Verify MySQL data
|
||||
|
||||
```bash
|
||||
pct exec 111 -- docker start seafile-mysql
|
||||
sleep 10
|
||||
pct exec 111 -- docker exec seafile-mysql mysql -uroot -p'PASSWORD' \
|
||||
-e "SELECT user,host FROM mysql.user WHERE user='seafile'; SHOW DATABASES;"
|
||||
# Should show: seafile@%, seafile_db, ccnet_db, seahub_db
|
||||
```
|
||||
|
||||
#### Step 6: Start Seafile
|
||||
|
||||
```bash
|
||||
pct exec 111 -- docker start seafile
|
||||
sleep 30
|
||||
pct exec 111 -- docker logs seafile --tail 20
|
||||
# Should show: "Seafile server started", "Seahub is started"
|
||||
```
|
||||
|
||||
#### Step 7: Run fsck
|
||||
|
||||
```bash
|
||||
pct exec 111 -- docker exec seafile bash -lc \
|
||||
'/opt/seafile/seafile-server-12.0.14/seaf-fsck.sh'
|
||||
```
|
||||
|
||||
With restored DB, fsck should connect and check all repos.
|
||||
|
||||
### Option B: Manual DB Init via `docker run` Setup Script (Preferred over SQL import)
|
||||
|
||||
When PBS restore fails and you need fresh DBs, the cleanest approach is running
|
||||
the official `setup-seafile-mysql.sh` in a throwaway container with proper env
|
||||
vars. This creates all schemas, users, and config files correctly.
|
||||
|
||||
**Critical**: The `bootstrap.py` in the main container skips `init_seafile_server()`
|
||||
when `/shared/seafile/seafile-data/` exists. To force fresh DB init:
|
||||
|
||||
1. Stop the seafile container
|
||||
2. Rename `seafile-data` temporarily: `mv .../seafile-data .../seafile-data.bak`
|
||||
3. Clear `conf/`: `rm -rf .../conf/*`
|
||||
4. Drop any half-created databases: `DROP DATABASE ccnet_db, seafile_db, seahub_db`
|
||||
5. Run setup in a throwaway container:
|
||||
|
||||
```bash
|
||||
docker run --rm --name seafile-setup --network seafile-net \
|
||||
-e SERVER_NAME=seafile \
|
||||
-e SERVER_IP=cloud.familie-schoen.com \
|
||||
-e MYSQL_USER=seafile \
|
||||
-e MYSQL_USER_PASSWD='SEAFILE_PW' \
|
||||
-e MYSQL_USER_HOST=% \
|
||||
-e MYSQL_HOST=db \
|
||||
-e MYSQL_PORT=3306 \
|
||||
-e MYSQL_ROOT_PASSWD='ROOT_PW' \
|
||||
-e CCNET_DB=ccnet_db \
|
||||
-e SEAFILE_DB=seafile_db \
|
||||
-e SEAHUB_DB=seahub_db \
|
||||
-e SEAFILE_SERVER_HOSTNAME=cloud.familie-schoen.com \
|
||||
-e SEAFILE_SERVER_PROTOCOL=https \
|
||||
-e INIT_SEAFILE_ADMIN_EMAIL=admin@example.com \
|
||||
-e INIT_SEAFILE_ADMIN_PASSWORD=password \
|
||||
-e TIME_ZONE=Europe/Berlin \
|
||||
-v /opt/seafile-data:/shared \
|
||||
seafileltd/seafile-mc:13.0-latest \
|
||||
/opt/seafile/seafile-server-13.0.19/setup-seafile-mysql.sh auto -n seafile
|
||||
```
|
||||
|
||||
⚠️ The env var names matter: `MYSQL_ROOT_PASSWD` (not `MYSQL_ROOT_PASSWORD`),
|
||||
`MYSQL_USER_PASSWD` (not `MYSQL_USER_PASSWORD`). The setup script reads
|
||||
`get_param_val(args.mysql_root_passwd, 'MYSQL_ROOT_PASSWD')` from `os.environ`.
|
||||
|
||||
6. Restore original `seafile-data` and `conf`:
|
||||
```bash
|
||||
rm -rf .../seafile-data && mv .../seafile-data.bak .../seafile-data
|
||||
# Restore conf from backup (contains correct DB credentials, SERVICE_URL, etc.)
|
||||
cp -a .../conf.bak/* .../conf/
|
||||
```
|
||||
|
||||
7. Update `SERVICE_URL` in `seahub_settings.py` if hostname changed:
|
||||
```bash
|
||||
sed -i 's|old.domain.com|new.domain.com|g' .../conf/seahub_settings.py
|
||||
```
|
||||
|
||||
8. Start the container normally — it will run upgrade scripts (12.0→13.0 etc.)
|
||||
and start Seafile.
|
||||
|
||||
### Option B2: Manual SQL Schema Import (Legacy Approach)
|
||||
|
||||
If `docker run` setup doesn't work, manually recreate databases and import schemas.
|
||||
|
||||
#### Step 1: Create databases and user
|
||||
|
||||
```bash
|
||||
docker exec seafile-mysql mysql -uroot -p'ROOT_PW' -e "
|
||||
CREATE DATABASE IF NOT EXISTS ccnet_db CHARACTER SET utf8 COLLATE utf8_general_ci;
|
||||
CREATE DATABASE IF NOT EXISTS seafile_db CHARACTER SET utf8 COLLATE utf8_general_ci;
|
||||
CREATE DATABASE IF NOT EXISTS seahub_db CHARACTER SET utf8 COLLATE utf8_general_ci;
|
||||
CREATE USER 'seafile'@'%' IDENTIFIED BY 'SEAFILE_PW';
|
||||
GRANT ALL PRIVILEGES ON ccnet_db.* TO 'seafile'@'%';
|
||||
GRANT ALL PRIVILEGES ON seafile_db.* TO 'seafile'@'%';
|
||||
GRANT ALL PRIVILEGES ON seahub_db.* TO 'seafile'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
"
|
||||
```
|
||||
|
||||
#### Step 2: Import SQL schemas
|
||||
|
||||
The seafile container has no `mysql` client. Use the seafile-mysql container:
|
||||
|
||||
```bash
|
||||
SQLDIR=/opt/seafile/seafile-server-12.0.14/sql/mysql
|
||||
|
||||
# ccnet schema
|
||||
docker exec seafile-mysql mysql -uroot -p'ROOT_PW' ccnet_db \
|
||||
< <(docker exec seafile cat $SQLDIR/ccnet.sql)
|
||||
|
||||
# seafile schema
|
||||
docker exec seafile-mysql mysql -uroot -p'ROOT_PW' seafile_db \
|
||||
< <(docker exec seafile cat $SQLDIR/seafile.sql)
|
||||
|
||||
# seahub schema (located in seahub/scripts/upgrade/sql/)
|
||||
docker exec seafile-mysql mysql -uroot -p'ROOT_PW' seahub_db \
|
||||
< <(docker exec seafile cat /opt/seafile/seafile-server-12.0.14/seahub/scripts/upgrade/sql/12.0.0/mysql/seahub.sql)
|
||||
```
|
||||
|
||||
⚠️ **This only recreates empty tables.** Existing repo metadata, users, and
|
||||
library mappings are lost. File blocks in `/opt/seafile-data/` remain but are
|
||||
orphaned (no DB entries pointing to them). The admin account must be recreated
|
||||
via `check_init_admin.py` or the Seafile web UI.
|
||||
|
||||
#### Step 3: Restart and run fsck
|
||||
|
||||
```bash
|
||||
docker restart seafile
|
||||
sleep 30
|
||||
docker exec seafile bash -lc '/opt/seafile/seafile-server-12.0.14/seaf-fsck.sh'
|
||||
```
|
||||
|
||||
### Option C: Reconstruct Repo Registrations from Commit Storage
|
||||
|
||||
When DBs are freshly initialized but `/opt/seafile-data/` (file blocks + commits)
|
||||
is intact, repos can be reconstructed by scanning the commit storage and
|
||||
registering them in the DB. See `references/seafile-repo-reconstruction.md` for
|
||||
the full procedure with Python scripts.
|
||||
|
||||
**Overview of the technique:**
|
||||
|
||||
1. **Scan commit storage**: `storage/commits/<repo_id>/<XX>/<YYYY...>/` — each
|
||||
file is a JSON object with `commit_id`, `root_id`, `parent_id`,
|
||||
`second_parent_id`, `repo_name`, `repo_desc`, `ctime`, `creator_name`.
|
||||
2. **Find HEAD commit**: The commit NOT referenced as `parent_id` by any other
|
||||
commit in the same repo. For repos with many commits (>1000), use
|
||||
`find -printf '%T@ %p\n' | sort -rn | head -20` to read only the newest 20.
|
||||
3. **Register in DB**: Insert into `Repo`, `Branch`, `RepoOwner`, and `RepoInfo`
|
||||
tables. The `Repo` table only needs `repo_id`; `Branch` needs `(name, repo_id,
|
||||
commit_id)`; `RepoOwner` needs `(repo_id, owner_id)`; `RepoInfo` needs
|
||||
`(repo_id, name, ...)`.
|
||||
4. **Run `seaf-fsck.sh --repair`**: After registering repos, fsck verifies
|
||||
integrity and repairs inconsistencies.
|
||||
|
||||
⚠️ **Encrypted repo commit IDs may have suffixes** (e.g.,
|
||||
`e155972728d6a3205726b94243078ae5f0aaaa3a.FYMWR3`) that exceed the 40-char
|
||||
`commit_id` column. Truncate to 40 chars before inserting.
|
||||
|
||||
⚠️ **`seaf-fsck.sh --repair` only checks registered repos** — it does NOT discover
|
||||
or register new repos. All repos must be registered in the DB first.
|
||||
|
||||
⚠️ **`seaf-fsck.sh --export` also only exports registered repos** — it cannot
|
||||
recover unregistered repos from the filesystem.
|
||||
|
||||
## Key Files in Seafile Docker Container
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `/opt/seafile/conf/seafile.conf` | Main config — DB host, user, password, fileserver port |
|
||||
| `/opt/seafile/seafile-server-VERSION/` | Server installation (scripts, SQL, binaries) |
|
||||
| `/opt/seafile/seafile-server-VERSION/seaf-fsck.sh` | Filesystem consistency checker |
|
||||
| `/opt/seafile/seafile-server-VERSION/seaf-gc.sh` | Garbage collector for orphaned blocks |
|
||||
| `/opt/seafile/seafile-server-VERSION/sql/mysql/` | SQL schema files (ccnet.sql, seafile.sql) |
|
||||
| `/opt/seafile/seafile-server-VERSION/seahub/scripts/upgrade/sql/VERSION/mysql/seahub.sql` | Seahub DB schema |
|
||||
| `/scripts/start.py` | Container entrypoint — calls `wait_for_mysql()` + `init_seafile_server()` |
|
||||
| `/scripts/utils.py` | `wait_for_mysql()` implementation |
|
||||
| `/scripts/bootstrap.py` | `init_seafile_server()` — skips if `seafile-data/` exists |
|
||||
|
||||
## Docker-Compose Layout (seafile-server.yml)
|
||||
|
||||
```yaml
|
||||
services:
|
||||
db:
|
||||
image: mariadb:10.11
|
||||
container_name: seafile-mysql
|
||||
volumes:
|
||||
- "${SEAFILE_MYSQL_VOLUME:-/opt/seafile-mysql/db}:/var/lib/mysql"
|
||||
# ...
|
||||
seafile:
|
||||
image: seafileltd/seafile-mc:12.0-latest
|
||||
container_name: seafile
|
||||
environment:
|
||||
- DB_HOST=db
|
||||
- DB_USER=seafile
|
||||
- DB_ROOT_PASSWD=${INIT_SEAFILE_MYSQL_ROOT_PASSWORD}
|
||||
- DB_PASSWORD=${SEAFILE_MYSQL_DB_PASSWORD}
|
||||
volumes:
|
||||
- "${SEAFILE_VOLUME:-/opt/seafile-data}:/shared"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
```
|
||||
|
||||
The `seafile` container's `/shared` mount corresponds to `/opt/seafile-data` on the
|
||||
host. Inside the container, `/opt/seafile/` is symlinked from `/shared/seafile/`.
|
||||
|
||||
## Network Architecture
|
||||
|
||||
```
|
||||
seafile-net (bridge, 172.18.0.0/16):
|
||||
├── seafile-mysql 172.18.0.3 (hostname: db)
|
||||
├── seafile-memcached 172.18.0.2
|
||||
├── seafile-caddy 172.18.0.4
|
||||
├── seafile 172.18.0.5
|
||||
└── seadoc 172.18.0.6
|
||||
```
|
||||
|
||||
`seafile.conf` references `host = db` which resolves via Docker DNS to the
|
||||
`seafile-mysql` container.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
1. **`wait_for_mysql()` uses `seafile` user, not `root`** — Once `seafile.conf`
|
||||
exists, the startup script connects as the `seafile` MySQL user. If that user
|
||||
doesn't exist (volume wipe), the loop runs forever. Container shows "Up" but
|
||||
Seafile never starts.
|
||||
|
||||
2. **`init_seafile_server()` skips when `seafile-data/` exists** — Even after
|
||||
recreating the MySQL user and empty databases, the bootstrap won't create
|
||||
tables because it sees existing data. Must import SQL schemas manually or
|
||||
restore the MySQL volume from backup.
|
||||
|
||||
3. **`seaf-fsck.sh` exits 0 even on DB failure** — It catches the DB connection
|
||||
error, prints a WARNING, and exits cleanly. Don't rely on exit code; check
|
||||
stderr for `[WARNING]` lines.
|
||||
|
||||
4. **No `mysql` client in seafile container** — Use `docker exec seafile-mysql
|
||||
mysql ...` for all MySQL operations. The seafile container only has Python
|
||||
and Seafile binaries.
|
||||
|
||||
5. **`seafile-data/` is large and usually intact** — When recovering, don't
|
||||
restore `/opt/seafile-data/` from backup unless corruption is suspected. It
|
||||
contains all file blocks (potentially hundreds of GB). Restoring only the
|
||||
MySQL volume + conf is much faster.
|
||||
|
||||
6. **PBS backup listing via `pvesh`** — `pvesh get /nodes/$(hostname)/storage/
|
||||
<storage>/content` outputs a Unicode table, not JSON. Parse with
|
||||
`awk -F'│'` to extract volids. See `proxmox-ve-administration` skill for
|
||||
PBS backup listing details.
|
||||
|
||||
7. **MariaDB vs MySQL image** — Seafile Docker uses `mariadb:10.11` but the
|
||||
config says `type = mysql`. MariaDB is compatible. The `MARIADB_AUTO_UPGRADE=1`
|
||||
env var handles schema upgrades on container start.
|
||||
|
||||
8. **Image version change can wipe MySQL volume** — Changing the Seafile Docker
|
||||
image (e.g., downgrading `seafile-mc:13.0-latest` → `seafile-mc:12.0-latest`
|
||||
or vice versa) can trigger MySQL volume reinitialization. The container detects
|
||||
a version mismatch and reinitializes the data directory, wiping all databases
|
||||
and users. **Always backup the MySQL volume (`/opt/seafile-mysql/db`) before
|
||||
changing the image version in `.env`.**
|
||||
|
||||
9. **Compare `.env` before restoring** — When recovering, always diff the current
|
||||
`.env` against any backup config. Key fields that differ between versions:
|
||||
- `SEAFILE_IMAGE` (e.g., `13.0-latest` vs `12.0-latest`)
|
||||
- `SEAFILE_SERVER_HOSTNAME` (e.g., `cloud.familie-schoen.com` vs
|
||||
`seafile.familie-schoen.com`)
|
||||
- `INIT_SEAFILE_ADMIN_EMAIL`
|
||||
- `JWT_PRIVATE_KEY`
|
||||
- `COMPOSE_FILE` list (e.g., `caddy.yml` may be added/removed)
|
||||
|
||||
Align the config BEFORE starting containers with a restored DB, otherwise
|
||||
Seafile may reinitialize or fail to match the restored data.
|
||||
|
||||
10. **Telegram file transfer corrupts large ZIPs** — Sending large binary archives
|
||||
(e.g., a MySQL data directory ZIP) via Telegram can truncate or corrupt the
|
||||
file. The ZIP arrives with valid local file headers but no central directory
|
||||
and invalid size fields (`0xFFFFFFFF`). For transferring server data between
|
||||
PVE nodes, use `scp`, `rsync`, or PBS restore instead of Telegram file
|
||||
attachments. If a received ZIP fails to open, verify with
|
||||
`python3 -c "import zipfile; zipfile.ZipFile('file.zip').namelist()"` — if it
|
||||
raises `BadZipFile`, the transfer corrupted it.
|
||||
|
||||
11. **`bootstrap.py` ENV variable substitution bug** — When `bootstrap.py`
|
||||
calls `setup-seafile-mysql.py` via `subprocess.call(cmd, env={...})`,
|
||||
the `env=` dict **replaces** the entire process environment rather than
|
||||
augmenting it. The `env=` dict includes `MYSQL_ROOT_PASSWD` (mapped from
|
||||
`INIT_SEAFILE_MYSQL_ROOT_PASSWORD`), but if the mapping is missing or
|
||||
misnamed, the subprocess gets `MYSQL_ROOT_PASSWD=''` (empty string),
|
||||
producing the error `using password: NO` from MySQL.
|
||||
|
||||
**Symptom**: `setup-seafile-mysql.py` fails with `Access denied for
|
||||
'root'@'localhost' (using password: NO)` even though
|
||||
`INIT_SEAFILE_MYSQL_ROOT_PASSWORD` is correctly set in the container env.
|
||||
|
||||
**Fix**: Run `setup-seafile-mysql.py` manually inside the container with
|
||||
all required ENV vars explicitly exported:
|
||||
```bash
|
||||
docker exec -e MYSQL_ROOT_PASSWD='<root_pw>' \
|
||||
-e MYSQL_USER='seafile' -e MYSQL_USER_PASSWD='<seafile_pw>' \
|
||||
-e MYSQL_HOST=db -e MYSQL_PORT=3306 \
|
||||
-e CCNET_DB=ccnet_db -e SEAFILE_DB=seafile_db -e SEAHUB_DB=seahub_db \
|
||||
-e SEAFILE_SERVER_HOSTNAME=cloud.example.com \
|
||||
-e INIT_SEAFILE_ADMIN_EMAIL=admin@example.com \
|
||||
-e INIT_SEAFILE_ADMIN_PASSWORD='<admin_pw>' \
|
||||
seafile /opt/seafile/seafile-server-13.0.19/setup-seafile-mysql.sh auto -n seafile
|
||||
```
|
||||
This bypasses `bootstrap.py`'s broken `env=` substitution and gives the
|
||||
setup script direct access to all required variables.
|
||||
|
||||
12. **PBS corrupted chunk blocks ALL file restoration** — A single 0-byte `.bad`
|
||||
chunk file on the PBS server (destroyed by garbage collection) makes the
|
||||
ENTIRE backup unrestorable. PBS aborts on the first file referencing the
|
||||
bad chunk, cleans up, and exits. There is no `--skip-bad-chunks` option.
|
||||
Files alphabetically before the affected file MAY be partially restored,
|
||||
but anything after is lost. The chunk path format is
|
||||
`/mnt/datastore/<ds>/.chunks/<XX>/<hash>.0.bad`.
|
||||
|
||||
13. **Multiple backup formats can ALL be unusable** — In this incident, THREE
|
||||
separate backup sources were tried and ALL failed:
|
||||
- PBS backup: corrupted chunk (above)
|
||||
- `seafile-mysql.zip` (Telegram-transferred): BadZipFile, no central directory
|
||||
- `seafile-mysql.tar.zst`: truncated, unexpected EOF after 4 entries
|
||||
- Offsite PBS: no CT111 backup existed at all
|
||||
Lesson: never assume ANY backup is restorable without verifying. Always
|
||||
test-restore before declaring a backup strategy viable.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Seafile & Immich Backup Strategy Analysis
|
||||
|
||||
## When to Use
|
||||
|
||||
When planning offsite/backup strategy for Seafile (558 GB) or Immich
|
||||
on bandwidth-limited remote PBS, and deciding what to back up vs.
|
||||
what to leave on Ceph-only redundancy.
|
||||
|
||||
## Seafile Backup Strategies (6 Options)
|
||||
|
||||
### Context
|
||||
|
||||
- Seafile Pro 13.0.19 MC Docker edition on CT111
|
||||
- File blocks: `/opt/seafile-data/seafile/seafile-data/storage/blocks/` (intrinsically deduplicated, content-addressable)
|
||||
- Config: `/opt/seafile/.env`, `seafile.conf`, `seahub_settings.py`
|
||||
- DB: MySQL (`seafile_db`, `ccnet_db`, `seahub_db`) — currently in `seafile-mysql` container, migration to Galera planned
|
||||
- Total size: ~558 GB (mostly file blocks)
|
||||
- Ceph redundancy: blocks stored on Ceph EC4+1 (5 HDD OSDs, 1-failure tolerance)
|
||||
|
||||
### Option Matrix
|
||||
|
||||
| # | Strategy | Offsite Volume | Pros | Cons |
|
||||
|---|----------|---------------|------|------|
|
||||
| 1 | **PBS Full (current)** | 558 GB init, then delta | Simple, everything included | Huge for offsite, slow restore |
|
||||
| 2 | **Config+DB only** | <1 GB | Fast daily offsite, trivial restore | Blocks only in Ceph — total Ceph loss = all files gone |
|
||||
| 3 | **SeafGC + PBS exclude blocks** | ~2 GB | Clean, regularly garbage-collected | Blocks only in Ceph, needs GC discipline |
|
||||
| 4 | **Seafile Native Backup** | Variable | Official way, incremental | Needs second Seafile server (not S3) |
|
||||
| 5 | **RBD Snapshot + PBS** | 558 GB init, delta like #1 | Point-in-time consistent | Ceph pool must support snapshots |
|
||||
| 6 | **Blocks on S3 (Pro feature)** | Rootfs ~2 GB offsite, blocks on S3 | Clean separation, Ceph relieved | Migration effort, S3 costs |
|
||||
|
||||
### Recommendation
|
||||
|
||||
- **Short-term**: Option 2 (Config+DB offsite) + regular `seaf-gc.sh`. Blocks
|
||||
are redundant in Ceph EC4+1. For DR against total Ceph loss: Option 4 as
|
||||
second pillar.
|
||||
- **Long-term**: Option 6 (S3 storage backend) — moves blocks out of Ceph
|
||||
entirely, making PBS offsite trivial (~2 GB) and Ceph only hosts config+DB.
|
||||
|
||||
## Seafile Pro S3 Storage Backend
|
||||
|
||||
Seafile Pro supports S3 as **primary storage backend** (not backup target).
|
||||
All file blocks are stored directly on S3 instead of local disk/Ceph.
|
||||
|
||||
### Configuration (in `seafile.conf`)
|
||||
|
||||
```ini
|
||||
[storage_backend]
|
||||
name = s3
|
||||
bucket = seafile-data
|
||||
key_id = XXX
|
||||
key = XXX
|
||||
region = eu-central-1
|
||||
endpoint = https://s3.example.com
|
||||
```
|
||||
|
||||
### Key Distinctions
|
||||
|
||||
- **Primary storage** — replaces local/Ceph block storage entirely
|
||||
- **NOT a backup target** — S3 is where live data lives, not a backup copy
|
||||
- **Migration required** — existing blocks must be migrated to S3
|
||||
- **Ceph relief** — once on S3, Ceph only holds config+DB (~2 GB), making
|
||||
PBS offsite backup trivial and fast
|
||||
|
||||
### When to Choose S3 Backend
|
||||
|
||||
- Ceph capacity is constrained (OSDs near full)
|
||||
- Offsite backup of 558 GB blocks is impractical (bandwidth-limited)
|
||||
- S3 storage is cheaper than maintaining Ceph OSDs
|
||||
- Want clean separation: config+DB on Ceph (small, fast), blocks on S3 (large, cheap)
|
||||
|
||||
## Seafile Native Backup (`seaf-backup-cmd`)
|
||||
|
||||
### How It Works
|
||||
|
||||
- Uses Seafile's internal RPC API to synchronize libraries to a **second
|
||||
Seafile server**
|
||||
- Incremental — only changed commits/blocks are transferred
|
||||
- The second server must be a full Seafile server (same version)
|
||||
- Commands: `seaf-backup-cmd status`, `seaf-backup-cmd sync <repo-id>`
|
||||
|
||||
### Limitations
|
||||
|
||||
- **Needs a second Seafile server** — not a simple file/rsync target
|
||||
- **Not S3-compatible** — target must be Seafile server with storage backend
|
||||
- **Setup complexity** — must configure backup server in `seafile.conf`
|
||||
- **Per-library sync** — no global "backup everything" by default
|
||||
|
||||
### Location
|
||||
|
||||
```bash
|
||||
# In the seafile container:
|
||||
docker exec seafile /opt/seafile/seafile-server-latest/seahub/scripts/seaf-backup-cmd.sh
|
||||
# Error without proper SEAFILE_DATA_DIR env — needs server running
|
||||
```
|
||||
|
||||
### When to Choose
|
||||
|
||||
- Need true DR replica (second running Seafile instance)
|
||||
- Have infrastructure for a second server
|
||||
- Want incremental, deduplicated replication (not full copies)
|
||||
|
||||
## Immich Backup Considerations
|
||||
|
||||
### Architecture
|
||||
|
||||
- CT115 (immich): stopped, 20 GB rootfs — upload directory location unclear
|
||||
- CT117 (immich-postgresql): running, 914 MB DB on proxmox6
|
||||
- Photos/videos are NOT deduplicated (unlike Seafile blocks)
|
||||
|
||||
### Strategy Options
|
||||
|
||||
| # | Strategy | Offsite Volume | Notes |
|
||||
|---|----------|---------------|-------|
|
||||
| 1 | PBS Full both CTs | ~21 GB | Simple but may grow |
|
||||
| 2 | DB dump + upload-dir rsync | DB ~1 GB + uploads | Selective |
|
||||
| 3 | DB only offsite | <1 GB | Photos only in Ceph |
|
||||
| 4 | Uploads on Ceph, DB on Galera | DB offsite <1 GB | Clean separation |
|
||||
|
||||
### Key Question
|
||||
|
||||
Where are the actual photo/video uploads stored? CT115 is stopped —
|
||||
uploads may be on a mount point, external volume, or within the
|
||||
PostgreSQL CT. Must identify before choosing a strategy.
|
||||
|
||||
## Bandwidth-Aware Offsite Backup Prioritization
|
||||
|
||||
When adding services to a bandwidth-limited offsite PBS:
|
||||
|
||||
### Tier 1: Small + Critical Infrastructure (add first)
|
||||
|
||||
- Traefik (~3 GB) — reverse proxy config, all routes
|
||||
- Gitea (~1.2 GB) — code repositories
|
||||
- Authelia, DNS — tiny but critical
|
||||
- Paperless (~16 GB) — documents (medium size, high value)
|
||||
|
||||
### Tier 2: Medium + Important
|
||||
|
||||
- Litellm (~3.4 GB) — API config
|
||||
- Seafile config+DB (~2 GB) — without blocks
|
||||
|
||||
### Tier 3: Evaluate Separately
|
||||
|
||||
- Seafile blocks (~558 GB) — consider S3 backend or native backup
|
||||
- Immich uploads — identify storage location first
|
||||
|
||||
### Skip
|
||||
|
||||
- Frigate (~40 GB) — rewritable video, low value
|
||||
- High-churn data that's easily regenerated
|
||||
|
||||
## PBS Offsite Job Configuration
|
||||
|
||||
### Adding CTs to Offsite Job
|
||||
|
||||
```bash
|
||||
# Edit /etc/pve/jobs.cfg from any PVE node (pmxcfs replicates)
|
||||
# Current: vmid 106
|
||||
# Target: vmid 106,108,104,99999
|
||||
sed -i '/offsite-ha-daily/,/^$/{s/vmid 106/vmid 106,108,104,99999/}' /etc/pve/jobs.cfg
|
||||
```
|
||||
|
||||
### Initial Full Backup (Manual)
|
||||
|
||||
Must run `vzdump` on the node where each CT actually runs (see
|
||||
`proxmox-ve-administration` → `references/pbs-lxc-setup-2026-07.md`
|
||||
pitfall #17 for the silent-skip issue):
|
||||
|
||||
```bash
|
||||
# Determine node for each CT first
|
||||
pvesh get /cluster/resources --type vm --output-format json | python3 -c "
|
||||
import json,sys
|
||||
for v in json.load(sys.stdin):
|
||||
if v.get('vmid') in [108,99999,104]:
|
||||
print(f\"CT{v['vmid']:5d} node={v['node']}\")"
|
||||
|
||||
# Run on correct nodes
|
||||
ssh root@10.0.20.60 "vzdump 108 --storage noris_offsite --mode snapshot --compress zstd"
|
||||
ssh root@10.0.20.70 "vzdump 99999 --storage noris_offsite --mode snapshot --compress zstd"
|
||||
ssh root@10.0.20.91 "vzdump 104 --storage noris_offsite --mode snapshot --compress zstd"
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
# Check backups arrived at PBS (may need content cache refresh)
|
||||
pvesh get /nodes/proxmox1/storage/noris_offsite/content --output-format json | \
|
||||
python3 -c "
|
||||
import json,sys,datetime
|
||||
for b in sorted(json.load(sys.stdin), key=lambda x: x.get('ctime',0), reverse=True)[:10]:
|
||||
print(f\"{b['volid']:55s} {b.get('size',0)//1024//1024:>8} MB {datetime.datetime.fromtimestamp(b.get('ctime',0)).strftime('%Y-%m-%d %H:%M')}\")"
|
||||
```
|
||||
|
||||
## Session Context (2026-07-07)
|
||||
|
||||
- Offsite job expanded: `vmid 106` → `vmid 106,108,104,99999`
|
||||
- Initial full backups triggered manually on correct nodes
|
||||
- CT111 (Seafile) backup showed 0 MB on noris_s3 — empty/failed backup
|
||||
(previous valid: 558 GB on 2026-07-06)
|
||||
- Frigate excluded from all backups per user decision
|
||||
- Seafile backup strategy discussion: user exploring alternatives to
|
||||
558 GB full backup, considering S3 storage backend (Pro feature)
|
||||
- Immich: CT115 stopped, upload location unidentified
|
||||
@@ -0,0 +1,247 @@
|
||||
# Seafile Repo Reconstruction from Commit Storage
|
||||
|
||||
## When to Use
|
||||
|
||||
After MySQL databases have been wiped/reinitialized but `/opt/seafile-data/`
|
||||
(file blocks + commit objects) is intact. This procedure registers all repos
|
||||
found in the commit storage back into the fresh MySQL DBs so they appear in the
|
||||
web UI and API.
|
||||
|
||||
## Commit Storage Layout
|
||||
|
||||
```
|
||||
/opt/seafile-data/seafile/seafile-data/storage/commits/
|
||||
<repo_id>/
|
||||
<first2chars_of_commit_sha>/
|
||||
<remaining38chars_of_commit_sha> ← JSON file, commit object
|
||||
```
|
||||
|
||||
Example: commit `5fb48bca960bf13a15df0b84dbee73ef1d8f98d6` lives at:
|
||||
`storage/commits/<repo_id>/5f/b48bca960bf13a15df0b84dbee73ef1d8f98d6`
|
||||
|
||||
## Commit Object Format (JSON)
|
||||
|
||||
Each commit file is a JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"commit_id": "5fb48bca960bf13a15df0b84dbee73ef1d8f98d6",
|
||||
"root_id": "0000000000000000000000000000000000000000",
|
||||
"repo_id": "3428f3b9-d6c0-4d23-8a03-f34ce8b888e1",
|
||||
"creator_name": "dominik@familie-schoen.com",
|
||||
"creator": "0000000000000000000000000000000000000000",
|
||||
"description": "Created library",
|
||||
"ctime": 1755169060,
|
||||
"parent_id": null,
|
||||
"second_parent_id": null,
|
||||
"repo_name": "Arztrechnungen",
|
||||
"repo_desc": "Arztrechnungen",
|
||||
"repo_category": null,
|
||||
"no_local_history": 1,
|
||||
"version": 1
|
||||
}
|
||||
```
|
||||
|
||||
## Finding the HEAD Commit
|
||||
|
||||
The HEAD commit is the one NOT referenced as `parent_id` (or `second_parent_id`)
|
||||
by any other commit in the same repo. Algorithm:
|
||||
|
||||
1. Read all (or top N by mtime) commit JSON objects for a repo
|
||||
2. Collect all `parent_id` and `second_parent_id` values into a set
|
||||
3. HEAD = the commit ID(s) NOT in that parent set
|
||||
4. If multiple HEADs exist (branch tips), pick the one with the latest `ctime`
|
||||
|
||||
### Performance for Large Repos
|
||||
|
||||
Some repos have tens of thousands of commits (34000+). Reading all JSON files
|
||||
is too slow (60s+ timeout). Optimization:
|
||||
|
||||
```bash
|
||||
# Get only the 20 newest commit files by filesystem mtime
|
||||
find "$repo_dir" -type f -printf '%T@ %p\n' | sort -rn | head -20
|
||||
```
|
||||
|
||||
Then parse only those 20 files. The HEAD is very likely among the newest commits.
|
||||
|
||||
⚠️ Filesystem mtime reflects when PBS restored the files, NOT the commit creation
|
||||
time. Don't rely on mtime alone for determining HEAD — use the parent-reference
|
||||
algorithm on the sampled commits.
|
||||
|
||||
## DB Tables to Populate
|
||||
|
||||
| Table | DB | Columns | Notes |
|
||||
|-------|-----|---------|-------|
|
||||
| `Repo` | `seafile_db` | `id` (auto), `repo_id` (unique) | Just insert the repo_id |
|
||||
| `Branch` | `seafile_db` | `id` (auto), `name`, `repo_id`, `commit_id` | name='master', commit_id=HEAD |
|
||||
| `RepoOwner` | `seafile_db` | `id` (auto), `repo_id` (unique), `owner_id` | owner_id = email address |
|
||||
| `RepoInfo` | `seafile_db` | `id` (auto), `repo_id` (unique), `name`, `update_time`, `version`, `is_encrypted`, `last_modifier`, `status` | name from commit JSON `repo_name` |
|
||||
|
||||
Without `RepoOwner`, repos won't appear in the API (`/api2/repos/`) even if
|
||||
registered in `Repo` and `Branch`. Without `RepoInfo`, repos show generic names.
|
||||
|
||||
## Step-by-Step Procedure
|
||||
|
||||
### Step 1: Generate SQL for Repo + Branch registration
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
import os, json, subprocess
|
||||
|
||||
COMMITS_DIR = "/opt/seafile-data/seafile/seafile-data/storage/commits"
|
||||
OWNER = "dominik@example.com" # admin email
|
||||
|
||||
sql_lines = ["-- Register repos"]
|
||||
for repo_id in sorted(os.listdir(COMMITS_DIR)):
|
||||
repo_dir = os.path.join(COMMITS_DIR, repo_id)
|
||||
if not os.path.isdir(repo_dir):
|
||||
continue
|
||||
# Get newest 20 commits by mtime
|
||||
result = subprocess.run(
|
||||
["find", repo_dir, "-type", "f", "-printf", "%T@ %p\n"],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
lines = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
||||
lines.sort(key=lambda x: float(x.split()[0]) if x.strip() else 0, reverse=True)
|
||||
top_files = [l.split(None, 1)[1] for l in lines[:20] if ' ' in l]
|
||||
|
||||
commits = {}
|
||||
all_parents = set()
|
||||
repo_name = None
|
||||
latest_ctime = 0
|
||||
latest_cid = None
|
||||
for fpath in top_files:
|
||||
parts = fpath.rstrip('/').split('/')
|
||||
commit_id = parts[-2] + parts[-1] # subdir + filename = full SHA
|
||||
try:
|
||||
with open(fpath, 'r') as f:
|
||||
data = json.load(f)
|
||||
commits[commit_id] = data
|
||||
if data.get('parent_id'):
|
||||
all_parents.add(data['parent_id'])
|
||||
if data.get('second_parent_id'):
|
||||
all_parents.add(data['second_parent_id'])
|
||||
if data.get('ctime', 0) > latest_ctime:
|
||||
latest_ctime = data['ctime']
|
||||
latest_cid = commit_id
|
||||
rn = data.get('repo_name')
|
||||
if rn:
|
||||
repo_name = rn
|
||||
except:
|
||||
pass
|
||||
|
||||
head_candidates = [c for c in commits if c not in all_parents]
|
||||
head_id = head_candidates[0] if len(head_candidates) == 1 else \
|
||||
max(head_candidates, key=lambda c: commits[c].get('ctime', 0)) if head_candidates else latest_cid
|
||||
if not head_id:
|
||||
continue
|
||||
if head_id in commits:
|
||||
rn = commits[head_id].get('repo_name')
|
||||
if rn:
|
||||
repo_name = rn
|
||||
if not repo_name:
|
||||
repo_name = f"Repo-{repo_id[:8]}"
|
||||
safe_name = repo_name.replace("'", "\\'")
|
||||
# Truncate encrypted commit IDs to 40 chars
|
||||
head_id_safe = head_id[:40]
|
||||
|
||||
sql_lines.append(f"INSERT IGNORE INTO seafile_db.Repo (repo_id) VALUES ('{repo_id}');")
|
||||
sql_lines.append(f"INSERT IGNORE INTO seafile_db.Branch (name, repo_id, commit_id) VALUES ('master', '{repo_id}', '{head_id_safe}');")
|
||||
sql_lines.append(f"INSERT IGNORE INTO seafile_db.RepoOwner (repo_id, owner_id) VALUES ('{repo_id}', '{OWNER}');")
|
||||
sql_lines.append(f"INSERT IGNORE INTO seafile_db.RepoInfo (repo_id, name, update_time, version, is_encrypted, last_modifier, status) VALUES ('{repo_id}', '{safe_name}', UNIX_TIMESTAMP(), 1, 0, '{OWNER}', 0);")
|
||||
|
||||
with open("/tmp/register_repos.sql", "w") as f:
|
||||
f.write("\n".join(sql_lines) + "\n")
|
||||
print(f"Generated {len(sql_lines)-1} statements")
|
||||
```
|
||||
|
||||
### Step 2: Execute the SQL
|
||||
|
||||
```bash
|
||||
docker exec -i seafile-mysql mysql -uroot -p'ROOT_PW' < /tmp/register_repos.sql
|
||||
```
|
||||
|
||||
### Step 3: Run seaf-fsck repair
|
||||
|
||||
```bash
|
||||
docker exec seafile /opt/seafile/seafile-server-13.0.19/seaf-fsck.sh --repair
|
||||
```
|
||||
|
||||
For large repos, run in background:
|
||||
```bash
|
||||
nohup docker exec seafile /opt/seafile/seafile-server-13.0.19/seaf-fsck.sh --repair \
|
||||
> /tmp/fsck-output.log 2>&1 &
|
||||
```
|
||||
|
||||
Monitor progress:
|
||||
```bash
|
||||
grep -c "Fsck finished" /tmp/fsck-output.log
|
||||
tail -5 /tmp/fsck-output.log
|
||||
```
|
||||
|
||||
### Step 4: Verify via API
|
||||
|
||||
```bash
|
||||
# Get auth token
|
||||
TOKEN=$(curl -s -X POST http://localhost:80/api2/auth-token/ \
|
||||
-d 'username=admin@example.com&password=PASSWORD' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
|
||||
|
||||
# List repos
|
||||
curl -s -H "Authorization: Token $TOKEN" http://localhost:80/api2/repos/ \
|
||||
| python3 -c "import sys,json; data=json.load(sys.stdin); print(f'{len(data)} repos visible')"
|
||||
|
||||
# Check a specific repo's contents
|
||||
curl -s -H "Authorization: Token $TOKEN" \
|
||||
"http://localhost:80/api2/repos/<repo_id>/dir/" \
|
||||
| python3 -c "import sys,json; data=json.load(sys.stdin); print(f'{len(data)} items')"
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
1. **Encrypted repo commit IDs have suffixes** — Encrypted repos append a suffix
|
||||
like `.FYMWR3` to the 40-char SHA1, producing a 46-char string that exceeds
|
||||
the `commit_id CHAR(41)` column. Error: `Data too long for column 'commit_id'`.
|
||||
Fix: truncate to 40 chars with `head_id[:40]`.
|
||||
|
||||
2. **`seaf-fsck.sh --repair` only checks REGISTERED repos** — It does NOT
|
||||
discover or register new repos from the filesystem. All repos must be inserted
|
||||
into the `Repo` table first. Same applies to `seaf-fsck.sh --export`.
|
||||
|
||||
3. **Repos invisible without `RepoOwner`** — Even with `Repo` + `Branch` entries,
|
||||
repos won't appear in `/api2/repos/` without a `RepoOwner` entry mapping the
|
||||
repo to a user's email.
|
||||
|
||||
4. **Repos show generic names without `RepoInfo`** — Without `RepoInfo`, repos
|
||||
appear as unnamed entries. The `name` column comes from the commit JSON's
|
||||
`repo_name` field.
|
||||
|
||||
5. **Repo sizes show 0 after reconstruction** — Size calculation happens during
|
||||
`seaf-fsck` or background indexing. Sizes will populate after fsck completes.
|
||||
|
||||
6. **Shares, groups, and non-admin users are lost** — This procedure restores
|
||||
repo ownership and file access, but share permissions, group memberships,
|
||||
and user accounts (except the admin) must be recreated manually.
|
||||
|
||||
7. **Large repos stall fsck** — Repos with 30000+ commits can take 10+ minutes
|
||||
during fsck. Run in background and monitor via `grep -c "Fsck finished"`.
|
||||
|
||||
8. **CRITICAL: Never run multiple fsck processes simultaneously** — If
|
||||
`seaf-fsck.sh --repair` is already running (check with
|
||||
`ps aux | grep seaf-fsck | grep -v grep`), do NOT start a second one — even
|
||||
via a different invocation method (e.g., one via `nohup` on the host and
|
||||
another via `docker exec`). Two concurrent `--repair` processes on the same
|
||||
storage can corrupt commit/block data. Always `kill -9` the duplicate before
|
||||
continuing. Only one `seaf-fsck` process should ever be running at a time.
|
||||
|
||||
9. **fsck can die silently (OOM-kill)** — `seaf-fsck.sh --repair` may be
|
||||
OOM-killed by the kernel with no trace in the log file. The log simply
|
||||
stops at whatever repo was being processed, and `ps aux | grep seaf-fsck`
|
||||
returns 0 processes. Always check process liveness, not just log tail.
|
||||
If killed, restart fsck — it resumes from where it left off (repos
|
||||
already checked are quickly re-verified).
|
||||
|
||||
10. **Large photo repos stall fsck indefinitely** — Repos with many large
|
||||
image files (e.g., "Fotos", "Lightroom Bilder") can take 30+ minutes
|
||||
each during fsck. Combined with concurrent Ceph I/O (recovery, RBD
|
||||
copies), fsck throughput drops to near-zero. If fsck appears stuck,
|
||||
check whether heavy I/O is competing and consider pausing other
|
||||
operations.
|
||||
Reference in New Issue
Block a user