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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions deploy/compose/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ CF_ENABLE_WATCH=true
CF_ENABLE_CONVERT_EXPORT=false
CF_LOCK_BACKEND=cloudfile
CF_ENABLE_CHECKOUT=false
# OPTIONAL Checkout is enabled only for these exact Seafile Repo UUIDs.
# Empty (default) = OFF for every library, even when CF_ENABLE_CHECKOUT=true.
# No REQUIRED mode and no extension-based auto Checkout. Examples:
# CF_CHECKOUT_REPOS=11111111-1111-4111-8111-111111111111,22222222-2222-4222-8222-222222222222
# Changing the list requires a coordinated restart of Hub / native C / Go.
# Remove a library only after its active Guard leases and pending edits are resolved.
CF_CHECKOUT_REPOS=
CLOUDFILE_EDITING_ENABLED=false
CLOUDFILE_RESOURCE_SECRET=
CF_ENABLE_LOCAL_APP=false
Expand Down
30 changes: 30 additions & 0 deletions deploy/compose/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,33 @@ docker compose logs -f cloudfile
镜像与发行包由 `build/cloudfile_14.0/build-in-docker.sh` + `image/cloudfile_14.0/docker-build.sh`
生成,来源 SHA 由根目录 `release.yaml` 解析后写入 `build-receipt.json`。
入口说明见根目录 `README.md`、`CLOUDFILE.md`、`AGENTS.md`。


## 库级 Checkout(OFF / OPTIONAL)

普通文件库默认 **OFF**,只使用 Seafile 原生版本与权限能力,不进入 Guard SQL;
专业库可显式设为 **OPTIONAL**,允许用户对单个文件主动 Checkout、Commit、
Checkin。本产品**不提供 REQUIRED**(强制全部签出)模式。

`CF_ENABLE_CHECKOUT=false` 是全局总开关(默认);开启总开关后,
仍然**只**对 `CF_CHECKOUT_REPOS` 中列出的 **Repo UUID** 启用 OPTIONAL:

```env
CF_ENABLE_CHECKOUT=true
CF_CHECKOUT_REPOS=11111111-1111-4111-8111-111111111111
```

`CF_CHECKOUT_REPOS` 为空则所有库保持 OFF。每个 UUID 必须是标准小写形式,
多库用逗号隔开。配置启动时校验并同步生成 Hub 与原生 `seafile.conf`
(Go/C 使用 `checkout_repo_ids`);变更需协调重启对应服务。
无新表、库级运行时数据库查询、文件类型自动切换或动态策略后台。

**运维变更纪律:** 将 OPTIONAL 库移出列表或关闭总开关之前,先停止新签出,
完成/取消该库所有活动 Checkout、处理 Agent 本地待确认任务,并确认不存在
未过期的 Guard 租约。不能通过关闭能力绕过尚未完成的独占编辑。
原先全局启用 Checkout 的部署升级时,必须先将仍有活动锁的库
纳入白名单,并在统一重启前完成核对。

仅 OPTIONAL 库依赖 Guard 表;Guard 失效时对应库拒绝不安全写入,
但未启用的库继续普通文件操作。与 Hub PR #50、
Agent2 #10 / #1 联动验收后再向 PROD 发布。
2 changes: 2 additions & 0 deletions deploy/compose/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ services:
JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY:-}
CF_LOCK_BACKEND: ${CF_LOCK_BACKEND:-cloudfile}
CF_ENABLE_CHECKOUT: ${CF_ENABLE_CHECKOUT:-false}
CF_CHECKOUT_REPOS: ${CF_CHECKOUT_REPOS:-}
# Keep explicit policy/transfer settings effective on both app and worker.
# Native administrator policy is initialized automatically from existing SQL settings.
CF_ENABLE_LOCAL_APP: ${CF_ENABLE_LOCAL_APP:-false}
Expand Down Expand Up @@ -483,6 +484,7 @@ services:
CF_ENABLE_CONVERT_EXPORT: ${CF_ENABLE_CONVERT_EXPORT:-false}
CF_LOCK_BACKEND: ${CF_LOCK_BACKEND:-cloudfile}
CF_ENABLE_CHECKOUT: ${CF_ENABLE_CHECKOUT:-false}
CF_CHECKOUT_REPOS: ${CF_CHECKOUT_REPOS:-}
# Keep explicit policy/transfer settings effective on both app and worker.
# Native administrator policy is initialized automatically from existing SQL settings.
CF_ENABLE_LOCAL_APP: ${CF_ENABLE_LOCAL_APP:-false}
Expand Down
24 changes: 24 additions & 0 deletions scripts/scripts_cf14.0/cloudfile_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import json
import os
import shutil
import uuid
from os.path import abspath, basename, dirname, exists, isdir, join

# get_proto() is upstream's (SEAFILE_SERVER_PROTOCOL / Letsencrypt aware). It is
Expand Down Expand Up @@ -126,6 +127,27 @@ def cf_enabled(name):
return get_conf(name, default).lower() == 'true'


def _checkout_repo_ids():
"""Canonical UUID allowlist for OPTIONAL Checkout; all other repos are OFF."""
raw = get_conf('CF_CHECKOUT_REPOS', '')
if len(raw) > 8192:
raise ValueError('CF_CHECKOUT_REPOS exceeds 8192 bytes')
if not raw.strip():
return ()
ids = []
for item in raw.split(','):
value = item.strip()
try:
if str(uuid.UUID(value)) != value:
raise ValueError('noncanonical UUID')
except (ValueError, AttributeError):
raise ValueError('CF_CHECKOUT_REPOS requires canonical Repo UUIDs')
if value in ids:
raise ValueError('CF_CHECKOUT_REPOS contains a duplicate Repo UUID')
ids.append(value)
return tuple(ids)


def _replace_block(path, begin, end, body):
"""Rewrite the region between `begin` and `end`, appending it if absent.

Expand Down Expand Up @@ -170,6 +192,7 @@ def write_cloudfile_settings():

for name in CF_FEATURE_SWITCHES:
body += '%s = %s\n' % (name, cf_enabled(name))
body += 'CF_CHECKOUT_REPOS = %r\n' % (_checkout_repo_ids(),)

# 只写标量,不要碰 DATABASES。
#
Expand Down Expand Up @@ -816,6 +839,7 @@ def _seafile_conf_cloudfile_lines():
key = name[len('CF_ENABLE_'):].lower() + '_enabled'
lines.append('%s = %s\n'
% (key, 'true' if cf_enabled(name) else 'false'))
lines.append('checkout_repo_ids = %s\n' % ','.join(_checkout_repo_ids()))

if cf_enabled('CF_ENABLE_FILE_LOCK'):
backend = get_conf('CF_LOCK_BACKEND', 'cloudfile').lower()
Expand Down
57 changes: 57 additions & 0 deletions tests/test_checkout_repo_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Isolated validation of the OFF / OPTIONAL repository configuration.

No Docker, database, or CloudFile runtime is needed.
"""
import ast
import uuid
import unittest
from pathlib import Path

SOURCE = Path(__file__).resolve().parents[1] / 'scripts/scripts_cf14.0/cloudfile_bootstrap.py'
TREE = ast.parse(SOURCE.read_text())
METHODS = {'_checkout_repo_ids', '_seafile_conf_cloudfile_lines'}
FUNCTIONS = [node for node in TREE.body if isinstance(node, ast.FunctionDef) and node.name in METHODS]


class CheckoutRepoConfigTest(unittest.TestCase):
def setUp(self):
self.env = {}
self.namespace = {
'uuid': uuid,
'get_conf': lambda name, default='': self.env.get(name, default),
'cf_enabled': lambda name: self.env.get(name, 'false') == 'true',
'CF_FEATURE_SWITCHES': ('CF_ENABLE_CHECKOUT',),
}
exec(compile(ast.Module(body=FUNCTIONS, type_ignores=[]), str(SOURCE), 'exec'),
self.namespace)

def test_no_selection_means_all_off(self):
self.assertEqual(self.namespace['_checkout_repo_ids'](), ())
rendered = ''.join(self.namespace['_seafile_conf_cloudfile_lines']())
self.assertIn('checkout_enabled = false\n', rendered)
self.assertIn('checkout_repo_ids = \n', rendered)

def test_global_switch_alone_does_not_enable_any_repo(self):
self.env['CF_ENABLE_CHECKOUT'] = 'true'
self.assertEqual(self.namespace['_checkout_repo_ids'](), ())
self.assertIn('checkout_repo_ids = \n',
''.join(self.namespace['_seafile_conf_cloudfile_lines']()))

def test_selected_repo_is_rendered_once(self):
repo = '11111111-1111-4111-8111-111111111111'
self.env.update(CF_ENABLE_CHECKOUT='true', CF_CHECKOUT_REPOS=repo)
self.assertEqual(self.namespace['_checkout_repo_ids'](), (repo,))
self.assertIn('checkout_repo_ids = ' + repo + '\n',
''.join(self.namespace['_seafile_conf_cloudfile_lines']()))

def test_invalid_lists_rejected_before_config_write(self):
good = '11111111-1111-4111-8111-111111111111'
for value in ['not-a-uuid', good + ',', good + ',' + good,
'AAAAAAAA-AAAA-4AAA-AAAA-AAAAAAAAAAAA', 'x' * 8193]:
self.env['CF_CHECKOUT_REPOS'] = value
with self.subTest(value=value[:40]), self.assertRaises(ValueError):
self.namespace['_checkout_repo_ids']()


if __name__ == '__main__':
unittest.main()
Loading