From fef351fe2f327efb3e82537a04e2a3c617e33123 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 20:24:04 +0900 Subject: [PATCH 01/10] fix(storage): skip full inventory on fresh V2 and ordinary restarts; preflight only CE transition --- scripts/scripts_cf14.0/library_storage.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/scripts/scripts_cf14.0/library_storage.py b/scripts/scripts_cf14.0/library_storage.py index e0a8e782..fa2f36e1 100644 --- a/scripts/scripts_cf14.0/library_storage.py +++ b/scripts/scripts_cf14.0/library_storage.py @@ -110,7 +110,7 @@ def _render_auto(original, generated_body): return ''.join(out).rstrip() + '\n\n' + BEGIN + '\n' + body + END + '\n' -def configure_auto_storage(conf, generated_body, installdir, environment=None): +def configure_auto_storage(conf, generated_body, installdir, environment=None, *, fresh=False): """Preflight old routing, register supported libraries, then replace config. Neither a failed preflight nor a failed apply changes seafile.conf. Apply @@ -129,6 +129,20 @@ def configure_auto_storage(conf, generated_body, installdir, environment=None): final = _render_auto(original, generated_body) check = ConfigParser(interpolation=None) check.read_string(final) + + # A fresh installation has no old repositories to register. Existing V2 + # already has persistent native RepoStorageId routes; do not rescan and + # rewrite every library on each restart. Preflight is only for an explicit + # transition from an existing non-V2 Seafile installation. + if fresh or current_root == LIBRARY_ROOT: + if current_root == LIBRARY_ROOT and ( + current.getboolean('storage', 'enable_storage_classes', fallback=False) or + any(current.get(section, 'name', fallback='filesystem') != 'filesystem' + for section in ('commit_object_backend', 'fs_object_backend', 'block_backend'))): + raise ValueError('Existing V2 storage configuration is incompatible') + _atomic_write(conf, final) + return + if not current.has_section('cloudfile_v2'): current.add_section('cloudfile_v2') current.set('cloudfile_v2', 'auto_local_root', LIBRARY_ROOT) From d90c80472ec789c732d87395f6daaf2b105a00d4 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 20:24:35 +0900 Subject: [PATCH 02/10] fix(storage): only explicit Seafile CE transitions run V2 inventory --- scripts/scripts_cf14.0/cloudfile_bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/scripts_cf14.0/cloudfile_bootstrap.py b/scripts/scripts_cf14.0/cloudfile_bootstrap.py index 56607695..2864f0d2 100644 --- a/scripts/scripts_cf14.0/cloudfile_bootstrap.py +++ b/scripts/scripts_cf14.0/cloudfile_bootstrap.py @@ -855,7 +855,7 @@ def write_cloudfile_seafile_conf(): conf_path = join(topdir, 'conf', 'seafile.conf') fresh = fresh_install() if auto_storage_enabled(conf_path, os.environ, fresh=fresh): - configure_auto_storage(conf_path, ''.join(lines), installdir) + configure_auto_storage(conf_path, ''.join(lines), installdir, fresh=fresh) return # Keep the S3 configuration in the same restart-safe generated block as From 4bd2fbd12d453adb8986c7ac7cc6bb12a721da31 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 20:27:53 +0900 Subject: [PATCH 03/10] test(storage): fresh UUID V2 and routine restart never run registration inventory --- tests/test_library_storage_config.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_library_storage_config.py b/tests/test_library_storage_config.py index 19db4016..1e665e06 100644 --- a/tests/test_library_storage_config.py +++ b/tests/test_library_storage_config.py @@ -188,6 +188,25 @@ def test_new_auto_uses_filesystem_and_reconciles_before_write(self): self.assertTrue(storage.auto_storage_enabled(self.conf, {})) self.assertEqual([], list(self.conf.parent.glob('.storage-*'))) + def test_fresh_uuid_v2_install_skips_native_inventory(self): + with patch.object(storage.subprocess, 'run') as run: + storage.configure_auto_storage(self.conf, '[cloudfile]\\nexample = true\\n', + self.installdir, self.env, fresh=True) + run.assert_not_called() + config = ConfigParser(interpolation=None) + config.read(self.conf) + self.assertEqual(str(self.root), config['cloudfile_v2']['auto_local_root']) + self.assertEqual('false', config['storage']['enable_storage_classes']) + + def test_existing_v2_restart_never_rewrites_route_registry(self): + self.configure() # explicit first transition from an existing CE config + calls_before = len(self.calls) + with patch.object(storage.subprocess, 'run') as run: + storage.configure_auto_storage(self.conf, '[cloudfile]\\nexample = true\\n', + self.installdir, self.env) + run.assert_not_called() + self.assertEqual(calls_before, len(self.calls)) + def test_native_layout_symlinks_are_resolved_but_managed_root_is_not(self): shared = self.top / 'shared-seafile' shared.mkdir() From 2cc10fa5e30ecdc3ea6102491852d1dd7fa6f45d Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 20:29:45 +0900 Subject: [PATCH 04/10] test(storage): use literal newline escapes in bootstrap fixture --- tests/test_library_storage_config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_library_storage_config.py b/tests/test_library_storage_config.py index 1e665e06..c3916e04 100644 --- a/tests/test_library_storage_config.py +++ b/tests/test_library_storage_config.py @@ -190,7 +190,7 @@ def test_new_auto_uses_filesystem_and_reconciles_before_write(self): def test_fresh_uuid_v2_install_skips_native_inventory(self): with patch.object(storage.subprocess, 'run') as run: - storage.configure_auto_storage(self.conf, '[cloudfile]\\nexample = true\\n', + storage.configure_auto_storage(self.conf, '[cloudfile]\nexample = true\n', self.installdir, self.env, fresh=True) run.assert_not_called() config = ConfigParser(interpolation=None) @@ -202,7 +202,7 @@ def test_existing_v2_restart_never_rewrites_route_registry(self): self.configure() # explicit first transition from an existing CE config calls_before = len(self.calls) with patch.object(storage.subprocess, 'run') as run: - storage.configure_auto_storage(self.conf, '[cloudfile]\\nexample = true\\n', + storage.configure_auto_storage(self.conf, '[cloudfile]\nexample = true\n', self.installdir, self.env) run.assert_not_called() self.assertEqual(calls_before, len(self.calls)) From 56c42451a169fbd4d23698ce6d79a9ec29cdb3ae Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 20:35:46 +0900 Subject: [PATCH 05/10] test(storage): one-off lightweight UUID V2 compatibility evidence --- .github/workflows/dev.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 53a4d90c..4131f346 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -51,3 +51,14 @@ jobs: run: | python3 -m unittest discover -s tests -p test_build_receipt.py -q python3 -m unittest discover -s tests -p test_current_build_policy.py -q + + uuid-v2-startup-contract: + if: github.event_name == 'pull_request' && github.event.pull_request.head.ref == 'fix/v2-uuid-bootstrap-20261024' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - name: Run V2 bootstrap configuration tests without any service + run: | + python3 -m unittest discover -s tests -p test_library_storage_config.py -q + python3 -m unittest discover -s tests -p test_v06_default_library_storage.py -q From 280ec027eea5cb441c66c924869586f064e8378a Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 20:37:50 +0900 Subject: [PATCH 06/10] test(storage): reset CE fixture between distinct explicit transition cases --- tests/test_library_storage_config.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_library_storage_config.py b/tests/test_library_storage_config.py index c3916e04..ae750746 100644 --- a/tests/test_library_storage_config.py +++ b/tests/test_library_storage_config.py @@ -233,6 +233,9 @@ def test_explicit_transition_inventories_original_classes(self): def test_schema_missing_and_state_drift_allow_explicit_apply(self): for status in ('SCHEMA_MISSING', 'STATE_DRIFT'): with self.subTest(status=status): + # Every case models a distinct explicit CE transition. + # A completed V2 restart intentionally skips reconcile. + self.conf.write_text(self.original) modes = [] def cli(command, **kwargs): modes.append(command[-1]) From 9af5f1bf9c41b68359a191e3b259360e6190155f Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 20:41:36 +0900 Subject: [PATCH 07/10] test(storage): align expectations with native route authority and Compose normalization --- tests/test_v06_default_library_storage.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_v06_default_library_storage.py b/tests/test_v06_default_library_storage.py index 96ce64c2..303beb8b 100644 --- a/tests/test_v06_default_library_storage.py +++ b/tests/test_v06_default_library_storage.py @@ -44,7 +44,11 @@ def test_merged_new_install_compose_contract(self): mounts = [v for v in process["volumes"] if v["target"] == parent] self.assertEqual(1, len(mounts), "one parent mount per process") self.assertEqual("rw", mounts[0].get("mode", "rw")) - self.assertFalse(mounts[0]["bind"]["create_host_path"]) + # Compose JSON may omit explicit false from the normalized form. + # The deployment overlay must still declare it for safe mounts. + self.assertFalse(mounts[0].get("bind", {}).get("create_host_path", False)) + self.assertEqual(2, (ROOT / "docker-compose.auto-library-storage.yml") + .read_text().count("create_host_path: false")) self.assertEqual(False, str(server["environment"]["CF_ENABLE_CHECKOUT"]).lower() == "true") From 39f5f276bfc3910af8d545fb1bcd64bb26510ec9 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sat, 24 Oct 2026 20:45:51 +0900 Subject: [PATCH 08/10] ci(storage): remove one-off targeted acceptance jobs after success --- .github/workflows/dev.yml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 4131f346..53a4d90c 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -51,14 +51,3 @@ jobs: run: | python3 -m unittest discover -s tests -p test_build_receipt.py -q python3 -m unittest discover -s tests -p test_current_build_policy.py -q - - uuid-v2-startup-contract: - if: github.event_name == 'pull_request' && github.event.pull_request.head.ref == 'fix/v2-uuid-bootstrap-20261024' - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - uses: actions/checkout@v4 - - name: Run V2 bootstrap configuration tests without any service - run: | - python3 -m unittest discover -s tests -p test_library_storage_config.py -q - python3 -m unittest discover -s tests -p test_v06_default_library_storage.py -q From 1f6d2e144dd4a7e4de4626582a0f8a075d5ad73f Mon Sep 17 00:00:00 2001 From: opendev9 Date: Sat, 24 Oct 2026 21:28:58 +0800 Subject: [PATCH 09/10] fix(seafobj): route V2 automatic-local objects through RepoStorageId The generated V2 config (auto_local_root plus filesystem backends) was rejected by seafobj's backend reader, so seahub could not start on a fresh V2 install. Map that config onto the reviewed multiple-backend machinery with one synthetic auto template and a native default: routed libraries read from their own key directory and original CE libraries keep the native tree. Prefer the complete MySQL facts from the environment before parsing a partial [database] section. --- patches/seafobj/automatic-local-storage.patch | 139 ++++++++++++------ 1 file changed, 94 insertions(+), 45 deletions(-) diff --git a/patches/seafobj/automatic-local-storage.patch b/patches/seafobj/automatic-local-storage.patch index f663bfa3..a4a8fc54 100644 --- a/patches/seafobj/automatic-local-storage.patch +++ b/patches/seafobj/automatic-local-storage.patch @@ -1,50 +1,6 @@ -diff --git a/seafobj/objstore_factory.py b/seafobj/objstore_factory.py -index c7f4219..f960e44 100644 ---- a/seafobj/objstore_factory.py -+++ b/seafobj/objstore_factory.py -@@ -4,7 +4,7 @@ import binascii - import logging - import json - --from sqlalchemy import select -+from sqlalchemy import select, text - - from seafobj.exceptions import InvalidConfigError - from seafobj.backends.filesystem import SeafObjStoreFS -@@ -638,7 +638,16 @@ class SeafObjStoreFactory(object): - except KeyError: - raise RuntimeError('unknown obj_type ' + obj_type) - -+ # Construct the lazy mapping before managers capture this dictionary; -+ # patching it at Django AppConfig.ready() would be too late for seafobj. -+ from seafobj.auto_local import AutoLocalStores, auto_template -+ parent = auto_template(self.seafile_cfg.multi_backend_cfg) -+ if parent is not None: -+ self.obj_stores[obj_type] = AutoLocalStores(parent, obj_type, self.seafile_cfg.crypto) -+ - for bend in self.seafile_cfg.multi_backend_cfg: -+ if bend.get('is_auto') is True: -+ continue - storage_id = bend['storage_id'] - - crypto = self.seafile_cfg.crypto -@@ -715,8 +724,12 @@ def get_repo_storage_id(repo_id): - RepoStorageId = Base.classes.RepoStorageId - storage_id = None - session = scoped_session(objstore_factory.session) -- r = session.scalars(select(RepoStorageId).where(RepoStorageId.repo_id == repo_id).limit(1)).first() -- storage_id = r.storage_id if r else None -+ # Virtual libraries share origin objects; match the native C/Go query -+ # rather than silently sending a virtual UUID to the legacy backend. -+ storage_id = session.scalar(text( -+ 'SELECT storage_id FROM RepoStorageId WHERE repo_id = COALESCE(' -+ '(SELECT origin_repo FROM VirtualRepo WHERE repo_id = :repo), :repo) LIMIT 1'), -+ {'repo': repo_id}) - repo_storage_id[repo_id] = storage_id - session.remove() - return storage_id diff --git a/seafobj/auto_local.py b/seafobj/auto_local.py new file mode 100644 +index 0000000..f5d9233 --- /dev/null +++ b/seafobj/auto_local.py @@ -0,0 +1,54 @@ @@ -102,3 +58,96 @@ new file mode 100644 + if item.get(kind) != {'backend': 'fs', 'dir': root}: + raise ValueError('Automatic local template requires one filesystem root') + return root +diff --git a/seafobj/db.py b/seafobj/db.py +index d64d743..377ae73 100644 +--- a/seafobj/db.py ++++ b/seafobj/db.py +@@ -33,7 +33,12 @@ def load_db_url_from_env(): + def create_engine_from_conf(config): + need_connection_pool_fix = True + +- if config.has_section('database'): ++ # CloudFile containers pass complete MySQL facts in the environment and ++ # keep only connection_charset in seafile.conf. Prefer the environment URL ++ # before parsing a [database] section that may legitimately be partial. ++ db_url = load_db_url_from_env() ++ ++ if db_url is None and config.has_section('database'): + backend = config.get('database', 'type') + + if backend == 'mysql': +diff --git a/seafobj/objstore_factory.py b/seafobj/objstore_factory.py +index c7f4219..c8de5f7 100644 +--- a/seafobj/objstore_factory.py ++++ b/seafobj/objstore_factory.py +@@ -4,7 +4,7 @@ import binascii + import logging + import json + +-from sqlalchemy import select ++from sqlalchemy import select, text + + from seafobj.exceptions import InvalidConfigError + from seafobj.backends.filesystem import SeafObjStoreFS +@@ -440,6 +440,29 @@ class SeafileConfig(object): + # Get storage backend from seafile.conf + cfg_file_parser = self.cfg_file_parser + ++ # CloudFile V2 automatic local root: every routed library keeps its own ++ # key directory while original CE libraries keep the native tree. ++ # Reuse the reviewed multiple-backend machinery with one synthetic auto ++ # template and a native default, so every object manager resolves the ++ # physical location through RepoStorageId exactly like C and Go do. ++ auto_root = '' ++ if cfg_file_parser.has_option('cloudfile_v2', 'auto_local_root'): ++ auto_root = (cfg_file_parser.get('cloudfile_v2', 'auto_local_root') or '').strip() ++ if auto_root: ++ def _fs_backend(root): ++ return {'backend': 'fs', 'dir': root} ++ native_root = os.path.dirname(self.get_seafile_storage_dir()) ++ self.storage_type = 'multiple' ++ self.multi_backend_cfg = [ ++ {'storage_id': 'auto-local', 'is_auto': True, 'is_default': False, ++ 'commits': _fs_backend(auto_root), 'fs': _fs_backend(auto_root), ++ 'blocks': _fs_backend(auto_root)}, ++ {'storage_id': 'native', 'is_default': True, ++ 'commits': _fs_backend(native_root), 'fs': _fs_backend(native_root), ++ 'blocks': _fs_backend(native_root)}, ++ ] ++ return ++ + enable_storage_classes = False + if self.storage_type == 'multiple': + enable_storage_classes = True +@@ -638,7 +661,16 @@ class SeafObjStoreFactory(object): + except KeyError: + raise RuntimeError('unknown obj_type ' + obj_type) + ++ # Construct the lazy mapping before managers capture this dictionary; ++ # patching it at Django AppConfig.ready() would be too late for seafobj. ++ from seafobj.auto_local import AutoLocalStores, auto_template ++ parent = auto_template(self.seafile_cfg.multi_backend_cfg) ++ if parent is not None: ++ self.obj_stores[obj_type] = AutoLocalStores(parent, obj_type, self.seafile_cfg.crypto) ++ + for bend in self.seafile_cfg.multi_backend_cfg: ++ if bend.get('is_auto') is True: ++ continue + storage_id = bend['storage_id'] + + crypto = self.seafile_cfg.crypto +@@ -715,8 +747,12 @@ def get_repo_storage_id(repo_id): + RepoStorageId = Base.classes.RepoStorageId + storage_id = None + session = scoped_session(objstore_factory.session) +- r = session.scalars(select(RepoStorageId).where(RepoStorageId.repo_id == repo_id).limit(1)).first() +- storage_id = r.storage_id if r else None ++ # Virtual libraries share origin objects; match the native C/Go query ++ # rather than silently sending a virtual UUID to the legacy backend. ++ storage_id = session.scalar(text( ++ 'SELECT storage_id FROM RepoStorageId WHERE repo_id = COALESCE(' ++ '(SELECT origin_repo FROM VirtualRepo WHERE repo_id = :repo), :repo) LIMIT 1'), ++ {'repo': repo_id}) + repo_storage_id[repo_id] = storage_id + session.remove() + return storage_id From 1e761ce22109ded1fd4ab74c88eb3b124e88f5e6 Mon Sep 17 00:00:00 2001 From: opendev9 Date: Sat, 24 Oct 2026 21:57:40 +0800 Subject: [PATCH 10/10] fix(seafobj): drop trailing whitespace from the patch body The quick source check runs git diff --check; blank context lines in the patch file must not carry the space marker. The applied source tree is unchanged. --- patches/seafobj/automatic-local-storage.patch | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/patches/seafobj/automatic-local-storage.patch b/patches/seafobj/automatic-local-storage.patch index a4a8fc54..66ce2662 100644 --- a/patches/seafobj/automatic-local-storage.patch +++ b/patches/seafobj/automatic-local-storage.patch @@ -65,7 +65,7 @@ index d64d743..377ae73 100644 @@ -33,7 +33,12 @@ def load_db_url_from_env(): def create_engine_from_conf(config): need_connection_pool_fix = True - + - if config.has_section('database'): + # CloudFile containers pass complete MySQL facts in the environment and + # keep only connection_charset in seafile.conf. Prefer the environment URL @@ -74,7 +74,7 @@ index d64d743..377ae73 100644 + + if db_url is None and config.has_section('database'): backend = config.get('database', 'type') - + if backend == 'mysql': diff --git a/seafobj/objstore_factory.py b/seafobj/objstore_factory.py index c7f4219..c8de5f7 100644 @@ -83,16 +83,16 @@ index c7f4219..c8de5f7 100644 @@ -4,7 +4,7 @@ import binascii import logging import json - + -from sqlalchemy import select +from sqlalchemy import select, text - + from seafobj.exceptions import InvalidConfigError from seafobj.backends.filesystem import SeafObjStoreFS @@ -440,6 +440,29 @@ class SeafileConfig(object): # Get storage backend from seafile.conf cfg_file_parser = self.cfg_file_parser - + + # CloudFile V2 automatic local root: every routed library keeps its own + # key directory while original CE libraries keep the native tree. + # Reuse the reviewed multiple-backend machinery with one synthetic auto @@ -122,7 +122,7 @@ index c7f4219..c8de5f7 100644 @@ -638,7 +661,16 @@ class SeafObjStoreFactory(object): except KeyError: raise RuntimeError('unknown obj_type ' + obj_type) - + + # Construct the lazy mapping before managers capture this dictionary; + # patching it at Django AppConfig.ready() would be too late for seafobj. + from seafobj.auto_local import AutoLocalStores, auto_template @@ -134,7 +134,7 @@ index c7f4219..c8de5f7 100644 + if bend.get('is_auto') is True: + continue storage_id = bend['storage_id'] - + crypto = self.seafile_cfg.crypto @@ -715,8 +747,12 @@ def get_repo_storage_id(repo_id): RepoStorageId = Base.classes.RepoStorageId