diff --git a/flip-api/src/flip_api/fl_services/services/fl_service.py b/flip-api/src/flip_api/fl_services/services/fl_service.py index f5023ef36..10b7c89de 100644 --- a/flip-api/src/flip_api/fl_services/services/fl_service.py +++ b/flip-api/src/flip_api/fl_services/services/fl_service.py @@ -85,38 +85,121 @@ def _normalise_job_type(job_type: str, fl_backend: FLBackend) -> str: return resolved -def list_local_base_files(base_dir: Path) -> list[str]: - """List every file under a local base-application directory, recursively. +# The only directory of a base template that is ever deployed. Everything a job actually needs at a +# trust lives under it: the NVFLARE ``app/config/*.json`` pair, or the Flower ``app/*.py`` modules +# named by ``[tool.flwr.app.components]``. The researcher's own files are added by the bundler +# afterwards (into ``app*/custom/`` for NVFLARE, or alongside for Flower) and never pass through +# this walk. +BUNDLED_APP_DIR_NAME = "app" + +# The single root file each backend needs beside ``app/``: +# NVFLARE meta.json — the job definition NVFLARE reads to deploy the app. +# Flower pyproject.toml — the FAB definition; ``[tool.flwr.app.components]`` resolves +# ``app.server_app:app`` relative to it, so it must sit at the run root. +BUNDLED_ROOT_FILES: dict[str, frozenset[str]] = { + FLBackend.NVFLARE: frozenset({"meta.json"}), + FLBackend.FLOWER: frozenset({"pyproject.toml"}), +} + +# Compiled Python is the one artefact that appears *inside* ``app/`` rather than at the template +# root, so positional allowlisting alone cannot exclude it — it needs naming. +EXCLUDED_APP_DIR_NAMES = frozenset({"__pycache__"}) +EXCLUDED_APP_FILE_SUFFIXES = (".pyc", ".pyo") + + +def list_local_base_files(base_dir: Path, allowed_root_files: frozenset[str]) -> list[str]: + """List the files of a local base-application template that belong in a deployed bundle. The base FL application templates live in the repo's ``fl-apps/`` tree, baked into the flip-api image and read from ``FL_APP_BASE_DIR`` (FLIP#724) — no longer from S3. This walks - ``base_dir`` and returns each file's path relative to it, so the bundler can mirror the tree - 1:1 into the destination bucket. + ``base_dir`` and returns each kept file's path relative to it, so the bundler can mirror them + into the destination bucket. + + Selection is a positive allowlist, not a denylist of known junk (FLIP#1008): everything under + ``app/`` plus the backend's single root file, and nothing else. A template directory is also a + live uv project root — it is where ``pyproject.toml`` sits — so it is exactly where ``uv sync``, + ``ruff``, ``pytest`` and friends write their caches. In dev that tree is bind-mounted into + flip-api, so a denylist would have to keep pace with every tool a developer might run, and a + single miss ships the artefact to every trust. Allowlisting is closed by construction: a + ``.venv`` is excluded because it is not ``app/``, not because it was enumerated. + + It also drops files that are real but have no business at a trust — ``recipe.py`` (a developer + driver that regenerates the committed configs), ``README.md``, and the per-template + ``required_files.json`` (the source for the backend-level manifest flip-api actually reads, one + directory up). A denylist could never exclude those, since they are not artefacts. Args: base_dir (Path): Root directory of a backend/job-type base application (``//``). + allowed_root_files (frozenset[str]): Filenames kept at ``base_dir`` itself — see + :data:`BUNDLED_ROOT_FILES`. Returns: - list[str]: Sorted relative POSIX paths of every regular file under ``base_dir`` (nested - paths included). Empty if ``base_dir`` does not exist or contains no files. + list[str]: Sorted relative POSIX paths of every kept file under ``base_dir``. Empty if + ``base_dir`` does not exist or contains nothing that qualifies. Note: Symlinks are ignored — both symlinked files and symlinked directories (``followlinks=False``). The default baked-in ``fl-apps/`` tree contains none, but ``FL_APP_BASE_DIR`` may point at an operator-provided tree; skipping symlinks keeps the walk inside the template tree so a stray link can't pull files from elsewhere on the host into the uploaded bundle. + + For that same operator-provided case the allowlist fails *closed*: an unrecognised file is + dropped rather than shipped. Every skipped path is logged at debug level so the omission is + diagnosable from the bundling logs rather than surfacing as a missing file at a trust. """ if not base_dir.is_dir(): return [] + rel_paths: list[str] = [] - for dirpath, _dirnames, filenames in os.walk(base_dir, followlinks=False): + skipped: list[str] = [] + + for entry in sorted(base_dir.iterdir()): + if entry.is_symlink(): + skipped.append(entry.name) + continue + if entry.is_dir(): + if entry.name != BUNDLED_APP_DIR_NAME: + skipped.append(f"{entry.name}/") + continue + rel_paths.extend(_walk_app_dir(entry, base_dir, skipped)) + elif entry.is_file(): + if entry.name in allowed_root_files: + rel_paths.append(entry.name) + else: + skipped.append(entry.name) + + if skipped: + logger.debug(f"Excluded from the {base_dir.name} bundle (not app/ or an allowed root file): {sorted(skipped)}") + return sorted(rel_paths) + + +def _walk_app_dir(app_dir: Path, base_dir: Path, skipped: list[str]) -> list[str]: + """Collect deployable files under a template's ``app/`` directory. + + Args: + app_dir (Path): The template's ``app/`` directory. + base_dir (Path): Template root, so returned paths stay relative to it. + skipped (list[str]): Accumulator for excluded paths, for the caller's debug log. + + Returns: + list[str]: Relative POSIX paths of the files to bundle. + """ + kept: list[str] = [] + for dirpath, dirnames, filenames in os.walk(app_dir, followlinks=False): + # In place, so os.walk does not descend (os.walk contract for topdown=True). + pruned = [d for d in dirnames if d in EXCLUDED_APP_DIR_NAMES] + dirnames[:] = [d for d in dirnames if d not in EXCLUDED_APP_DIR_NAMES] + skipped.extend(f"{Path(dirpath).relative_to(base_dir).as_posix()}/{d}/" for d in pruned) + for name in filenames: path = Path(dirpath) / name - if path.is_symlink(): + rel = path.relative_to(base_dir).as_posix() + if path.is_symlink() or name.endswith(EXCLUDED_APP_FILE_SUFFIXES): + skipped.append(rel) continue - rel_paths.append(path.relative_to(base_dir).as_posix()) - return sorted(rel_paths) + kept.append(rel) + return kept def upload_app(model_id: UUID, training_details: IStartTrainingBody, endpoint: str) -> Any: @@ -574,9 +657,24 @@ def bundle_nvflare_application(model_id: UUID, job_type: str = DEFAULT_JOB_TYPE) # (FLIP#724) — there is no S3 base bucket. base_dir = Path(get_settings().FL_APP_BASE_DIR) / FLBackend.NVFLARE / job_type logger.debug(f"Base application dir: {base_dir}") - base_rel_paths = list_local_base_files(base_dir) + allowed_root_files = BUNDLED_ROOT_FILES[FLBackend.NVFLARE] + base_rel_paths = list_local_base_files(base_dir, allowed_root_files) if not base_rel_paths: - raise FileNotFoundError(f"Base application files missing in the local base directory: {base_dir}") + # Reached when the directory exists but holds nothing deployable. Name the rule, so an + # operator looking at a visibly non-empty template directory is not left guessing; + # the debug log above lists exactly what was excluded. + raise FileNotFoundError( + f"Base application files missing in the local base directory: {base_dir} " + f"(a bundle needs an app/ directory and one of {sorted(allowed_root_files)})" + ) + if not allowed_root_files.intersection(base_rel_paths): + # A template whose app/ files survived the walk but whose root file did not would bundle + # cleanly and only fail at the FL server, far from the cause — NVFLARE has no meta.json + # job definition to deploy. Fail here instead, naming what the template must carry. + raise FileNotFoundError( + f"Base application root file missing in the local base directory: {base_dir} " + f"(a bundle needs one of {sorted(allowed_root_files)} beside {BUNDLED_APP_DIR_NAME}/)" + ) # Clear destination if files already exist there (e.g. from a previous training run) dest_files = s3.list_objects(dest_bucket_s3_path) @@ -796,9 +894,25 @@ def bundle_flower_application(model_id: UUID, job_type: str = DEFAULT_JOB_TYPE) # (FLIP#724) — there is no S3 base bucket. base_dir = Path(get_settings().FL_APP_BASE_DIR) / FLBackend.FLOWER / job_type logger.debug(f"Base application dir: {base_dir}") - base_rel_paths = list_local_base_files(base_dir) + allowed_root_files = BUNDLED_ROOT_FILES[FLBackend.FLOWER] + base_rel_paths = list_local_base_files(base_dir, allowed_root_files) if not base_rel_paths: - raise FileNotFoundError(f"Base application files missing in the local base directory: {base_dir}") + # Reached when the directory exists but holds nothing deployable. Name the rule, so an + # operator looking at a visibly non-empty template directory is not left guessing; + # the debug log above lists exactly what was excluded. + raise FileNotFoundError( + f"Base application files missing in the local base directory: {base_dir} " + f"(a bundle needs an app/ directory and one of {sorted(allowed_root_files)})" + ) + if not allowed_root_files.intersection(base_rel_paths): + # A template whose app/ files survived the walk but whose root file did not would bundle + # cleanly and only fail at the FL server, far from the cause — fl-api-flower cannot build + # a FAB without the run-root pyproject.toml. Fail here instead, naming what the template + # must carry. + raise FileNotFoundError( + f"Base application root file missing in the local base directory: {base_dir} " + f"(a bundle needs one of {sorted(allowed_root_files)} beside {BUNDLED_APP_DIR_NAME}/)" + ) # Clear destination if files already exist there (e.g. from a previous training run) dest_files = s3.list_objects(dest_bucket_s3_path) diff --git a/flip-api/tests/unit/fl_services/services/test_fl_service.py b/flip-api/tests/unit/fl_services/services/test_fl_service.py index 3d742d46a..ce290f2fe 100644 --- a/flip-api/tests/unit/fl_services/services/test_fl_service.py +++ b/flip-api/tests/unit/fl_services/services/test_fl_service.py @@ -11,6 +11,7 @@ # import json +import logging from pathlib import Path from unittest.mock import MagicMock, patch from uuid import UUID, uuid4 @@ -343,7 +344,7 @@ def test_bundle_nvflare_application_success( dest_bucket = mocked_settings.FL_APP_DESTINATION_BUCKET # Base application template on the local FL_APP_BASE_DIR tree - write_base_tree(base_dir, "nvflare", "standard", ["app/file1.py"]) + write_base_tree(base_dir, "nvflare", "standard", ["app/file1.py", "meta.json"]) mock_client = mock_s3.return_value # Ensure get_object returns a body whose read() yields the config.json bytes @@ -399,7 +400,7 @@ def test_bundle_nvflare_application_diverts_eval_checkpoint( dest_bucket = mocked_settings.FL_APP_DESTINATION_BUCKET # The tree lives under the RESOLVED name only — proving the alias path reads it from there. - write_base_tree(base_dir, "nvflare", "evaluation", ["app/custom/flip.py"]) + write_base_tree(base_dir, "nvflare", "evaluation", ["app/custom/flip.py", "meta.json"]) eval_config = { "job_type": job_type, @@ -458,7 +459,7 @@ def test_bundle_nvflare_application_diverts_standard_server_checkpoint( model_bucket = mocked_settings.SCANNED_MODEL_FILES_BUCKET dest_bucket = mocked_settings.FL_APP_DESTINATION_BUCKET - write_base_tree(base_dir, "nvflare", "standard", ["app/custom/flip.py"]) + write_base_tree(base_dir, "nvflare", "standard", ["app/custom/flip.py", "meta.json"]) std_config = {"job_type": "standard", "SERVER_CHECKPOINT": "pretrained_weights.pt"} mock_client = mock_s3.return_value @@ -517,7 +518,9 @@ def test_bundle_nvflare_application_model_files_overwrite( dest_bucket = mocked_settings.FL_APP_DESTINATION_BUCKET # Base template contains flip.py under app/custom — a name the researcher must not overwrite - write_base_tree(base_dir, "nvflare", "standard", ["app/custom/flip.py", "app/config/config_fed_client.json"]) + write_base_tree( + base_dir, "nvflare", "standard", ["app/custom/flip.py", "app/config/config_fed_client.json", "meta.json"] + ) mock_client = mock_s3.return_value # config.json with job_type standard @@ -606,7 +609,7 @@ def test_bundle_nvflare_application_file_wrong_job_type_in_config( # A base template exists for the parametrized job_type (unused for the "invalid" run, which is # rejected before the base directory is ever walked). - write_base_tree(base_dir, "nvflare", job_type, ["app/file1.py"]) + write_base_tree(base_dir, "nvflare", job_type, ["app/file1.py", "meta.json"]) mock_is_valid.side_effect = lambda jt, backend: jt in mock_job_types_file mock_client = mock_s3.return_value @@ -645,7 +648,7 @@ def test_bundle_nvflare_application_wrong_files(mock_s3, mock_required, mock_ver base_dir = mocked_settings.FL_APP_BASE_DIR model_bucket = mocked_settings.SCANNED_MODEL_FILES_BUCKET - write_base_tree(base_dir, "nvflare", "standard", ["app/file1.py"]) + write_base_tree(base_dir, "nvflare", "standard", ["app/file1.py", "meta.json"]) mock_client = mock_s3.return_value # Provide an empty JSON config for tests that include config.json in model files @@ -806,7 +809,7 @@ def test_bundle_flower_application_file_wrong_job_type_in_config( # A base template exists for the parametrized job_type (unused for the "invalid" run, which is # rejected before the base directory is ever walked). - write_base_tree(base_dir, "flower", job_type, ["app/file1.py"]) + write_base_tree(base_dir, "flower", job_type, ["app/file1.py", "pyproject.toml"]) mock_is_valid.side_effect = lambda jt, backend: jt in mock_job_types_file mock_client = mock_s3.return_value @@ -844,7 +847,7 @@ def test_bundle_flower_application_wrong_files(mock_s3, mock_required, mocked_se base_dir = mocked_settings.FL_APP_BASE_DIR model_bucket = mocked_settings.SCANNED_MODEL_FILES_BUCKET - write_base_tree(base_dir, "flower", "standard", ["app/server_app.py"]) + write_base_tree(base_dir, "flower", "standard", ["app/server_app.py", "pyproject.toml"]) mock_client = mock_s3.return_value mock_client.get_object.return_value = { @@ -1316,10 +1319,44 @@ def test_bundle_nvflare_application_no_base_files(mock_s3, mocked_settings, mode fl_service.bundle_nvflare_application(model_id) +@patch("flip_api.fl_services.services.fl_service.JobRequiredFiles.is_valid_job_type", return_value=True) +@patch("flip_api.fl_services.services.fl_service.S3Client") +def test_bundle_nvflare_application_missing_root_file(mock_s3, mock_is_valid, mocked_settings, model_id): + """A template with ``app/`` files but no ``meta.json`` is rejected at bundle time. + + Without this guard the bundle uploads cleanly and only fails at the FL server, far from the + cause — NVFLARE has no job definition to deploy. + """ + base_dir = mocked_settings.FL_APP_BASE_DIR + model_bucket = mocked_settings.SCANNED_MODEL_FILES_BUCKET + + write_base_tree(base_dir, "nvflare", "standard", ["app/file1.py"]) # app/ present, meta.json absent + + mock_client = mock_s3.return_value + mock_client.get_object.return_value = { + "Body": MagicMock(read=MagicMock(return_value=json.dumps({"job_type": "standard"}).encode("utf-8"))) + } + mock_client.list_objects.side_effect = [ + [ + f"{model_bucket}/{model_id}/trainer.py", + f"{model_bucket}/{model_id}/config.json", + ], + ] + + with pytest.raises(FileNotFoundError, match="Base application root file missing"): + fl_service.bundle_nvflare_application(model_id) + + @patch("flip_api.fl_services.services.fl_service.JobRequiredFiles.get_required_files") @patch("flip_api.fl_services.services.fl_service.S3Client") def test_bundle_nvflare_application_no_app_folders(mock_s3, mock_required, mocked_settings, model_id): - """Base tree without any top-level ``app*`` folder is rejected.""" + """Base tree without an ``app/`` folder is rejected. + + Under the FLIP#1008 allowlist nothing outside ``app/`` is bundleable, so a template holding + only ``notapp/`` yields an empty file list and is rejected at the first check rather than + later by the app-folder scan. The error names the rule and the debug log lists what was + excluded, so a visibly non-empty directory reporting "missing" is still diagnosable. + """ base_dir = mocked_settings.FL_APP_BASE_DIR model_bucket = mocked_settings.SCANNED_MODEL_FILES_BUCKET @@ -1338,7 +1375,7 @@ def test_bundle_nvflare_application_no_app_folders(mock_s3, mock_required, mocke ] mock_client.copy_object.return_value = None - with pytest.raises(FileNotFoundError, match="No app folders found under base application"): + with pytest.raises(FileNotFoundError, match="Base application files missing"): fl_service.bundle_nvflare_application(model_id) @@ -1357,7 +1394,7 @@ def test_bundle_nvflare_application_clears_existing_dest( model_bucket = mocked_settings.SCANNED_MODEL_FILES_BUCKET dest_bucket = mocked_settings.FL_APP_DESTINATION_BUCKET - write_base_tree(base_dir, "nvflare", "standard", ["app/file1.py"]) + write_base_tree(base_dir, "nvflare", "standard", ["app/file1.py", "meta.json"]) mock_client = mock_s3.return_value mock_client.get_object.return_value = { @@ -1421,7 +1458,7 @@ def test_bundle_nvflare_application_empty_manifest_is_rejected( """ model_bucket = mocked_settings.SCANNED_MODEL_FILES_BUCKET - write_base_tree(mocked_settings.FL_APP_BASE_DIR, "nvflare", "standard", ["app/file1.py"]) + write_base_tree(mocked_settings.FL_APP_BASE_DIR, "nvflare", "standard", ["app/file1.py", "meta.json"]) mock_client = mock_s3.return_value mock_client.get_object.return_value = { @@ -1470,6 +1507,34 @@ def test_bundle_flower_application_no_base_files(mock_s3, mocked_settings, model fl_service.bundle_flower_application(model_id) +@patch("flip_api.fl_services.services.fl_service.JobRequiredFiles.is_valid_job_type", return_value=True) +@patch("flip_api.fl_services.services.fl_service.S3Client") +def test_bundle_flower_application_missing_root_file(mock_s3, mock_is_valid, mocked_settings, model_id): + """A template with ``app/`` files but no ``pyproject.toml`` is rejected at bundle time. + + Without this guard the bundle uploads cleanly and only fails at the FL server, far from the + cause — fl-api-flower cannot build a FAB without the run-root ``pyproject.toml``. + """ + base_dir = mocked_settings.FL_APP_BASE_DIR + model_bucket = mocked_settings.SCANNED_MODEL_FILES_BUCKET + + write_base_tree(base_dir, "flower", "standard", ["app/server_app.py"]) # app/ present, pyproject.toml absent + + mock_client = mock_s3.return_value + mock_client.get_object.return_value = { + "Body": MagicMock(read=MagicMock(return_value=json.dumps({"job_type": "standard"}).encode("utf-8"))) + } + mock_client.list_objects.side_effect = [ + [ + f"{model_bucket}/{model_id}/client_app.py", + f"{model_bucket}/{model_id}/config.json", + ], + ] + + with pytest.raises(FileNotFoundError, match="Base application root file missing"): + fl_service.bundle_flower_application(model_id) + + @patch("flip_api.fl_services.services.fl_service.JobRequiredFiles.is_valid_job_type", return_value=True) @patch("flip_api.fl_services.services.fl_service.JobRequiredFiles.get_required_files") @patch("flip_api.fl_services.services.fl_service.S3Client") @@ -1752,9 +1817,13 @@ def test_abort_model_training_raises_on_invalid_target( # --- local base-file helper + local-directory bundling edge cases (FLIP#724) ---------------------- +FLOWER_ROOT = fl_service.BUNDLED_ROOT_FILES[FLBackend.FLOWER] +NVFLARE_ROOT = fl_service.BUNDLED_ROOT_FILES[FLBackend.NVFLARE] + + def test_list_local_base_files_missing_dir_returns_empty(tmp_path): # A non-existent base directory yields no files (bundlers turn this into FileNotFoundError). - assert fl_service.list_local_base_files(tmp_path / "does-not-exist") == [] + assert fl_service.list_local_base_files(tmp_path / "does-not-exist", FLOWER_ROOT) == [] def test_list_local_base_files_skips_symlinks(tmp_path): @@ -1772,8 +1841,90 @@ def test_list_local_base_files_skips_symlinks(tmp_path): (base / "app" / "link.py").symlink_to(external_file) # symlinked file (base / "linked_dir").symlink_to(external_dir) # symlinked directory + (base / "app" / "linked_sub").symlink_to(external_dir) # symlinked dir INSIDE app/ + + assert fl_service.list_local_base_files(base, FLOWER_ROOT) == ["app/real.py"] + + +def test_list_local_base_files_excludes_local_dev_artefacts(tmp_path): + # In dev the repo's fl-apps/ tree is bind-mounted into flip-api, so a developer's .venv, + # __pycache__ or tool caches sit inside the template directory and would otherwise be + # mirrored into the bucket and shipped to every trust. None of them is enumerated: they are + # excluded because they are neither app/ nor an allowed root file (FLIP#1008). + base = tmp_path / "base" + (base / "app").mkdir(parents=True) + (base / "app" / "client_app.py").write_text("x") + (base / "pyproject.toml").write_text("x") + + (base / ".venv" / "lib" / "python3.12" / "site-packages").mkdir(parents=True) + (base / ".venv" / "pyvenv.cfg").write_text("x") + (base / ".venv" / "lib" / "python3.12" / "site-packages" / "_virtualenv.py").write_text("x") + (base / ".ruff_cache").mkdir() + (base / ".ruff_cache" / "CACHEDIR.TAG").write_text("x") + (base / ".DS_Store").write_text("x") + + assert fl_service.list_local_base_files(base, FLOWER_ROOT) == ["app/client_app.py", "pyproject.toml"] + + +def test_list_local_base_files_excludes_unknown_tool_output(tmp_path): + # The point of allowlisting over a denylist: a cache from a tool nobody enumerated is still + # excluded, because the rule is positional rather than a list of known offenders. + base = tmp_path / "base" + (base / "app").mkdir(parents=True) + (base / "app" / "server_app.py").write_text("x") + (base / "pyproject.toml").write_text("x") + + for unknown in (".tox", ".nox", "htmlcov", ".idea", "dist", "standard_app.egg-info"): + (base / unknown).mkdir() + (base / unknown / "junk.txt").write_text("x") - assert fl_service.list_local_base_files(base) == ["app/real.py"] + assert fl_service.list_local_base_files(base, FLOWER_ROOT) == ["app/server_app.py", "pyproject.toml"] + + +def test_list_local_base_files_excludes_compiled_python_inside_app(tmp_path): + # __pycache__/*.pyc is the one artefact that appears INSIDE app/, so position alone cannot + # exclude it — it is named explicitly. + base = tmp_path / "base" + (base / "app" / "__pycache__").mkdir(parents=True) + (base / "app" / "client_app.py").write_text("x") + (base / "app" / "__pycache__" / "client_app.cpython-312.pyc").write_text("x") + (base / "app" / "stale.pyc").write_text("x") + (base / "app" / "stale.pyo").write_text("x") + (base / "pyproject.toml").write_text("x") + + assert fl_service.list_local_base_files(base, FLOWER_ROOT) == ["app/client_app.py", "pyproject.toml"] + + +def test_list_local_base_files_excludes_developer_files_that_are_not_artefacts(tmp_path): + # recipe.py regenerates the committed configs on a developer's workstation; README.md is + # documentation; the per-template required_files.json is the source for the backend-level + # manifest flip-api reads one directory up. All three are real files that a denylist could + # never exclude, and none of them belongs at a trust. + base = tmp_path / "base" + (base / "app" / "config").mkdir(parents=True) + (base / "app" / "config" / "config_fed_server.json").write_text("{}") + (base / "meta.json").write_text("{}") + (base / "recipe.py").write_text("x") + (base / "README.md").write_text("x") + (base / "required_files.json").write_text("[]") + + assert fl_service.list_local_base_files(base, NVFLARE_ROOT) == [ + "app/config/config_fed_server.json", + "meta.json", + ] + + +def test_list_local_base_files_root_file_is_per_backend(tmp_path): + # Each backend keeps exactly one root file: NVFLARE's job definition, Flower's FAB definition. + # The other backend's is not deployable here and must not ride along. + base = tmp_path / "base" + (base / "app").mkdir(parents=True) + (base / "app" / "keep.py").write_text("x") + (base / "meta.json").write_text("{}") + (base / "pyproject.toml").write_text("x") + + assert fl_service.list_local_base_files(base, NVFLARE_ROOT) == ["app/keep.py", "meta.json"] + assert fl_service.list_local_base_files(base, FLOWER_ROOT) == ["app/keep.py", "pyproject.toml"] def test_list_local_base_files_returns_sorted_nested_relpaths(tmp_path): @@ -1784,13 +1935,30 @@ def test_list_local_base_files_returns_sorted_nested_relpaths(tmp_path): (tmp_path / "pyproject.toml").write_text("x") # Directories are not returned, only files; paths are relative POSIX and sorted. - assert fl_service.list_local_base_files(tmp_path) == [ + assert fl_service.list_local_base_files(tmp_path, FLOWER_ROOT) == [ "app/config/config_fed_server.json", "app/custom/sub/deep.py", "pyproject.toml", ] +def test_list_local_base_files_logs_what_it_dropped(tmp_path, caplog): + # FL_APP_BASE_DIR may point at an operator-provided tree, where the allowlist fails CLOSED. + # The debug log is what makes such an omission diagnosable from the bundling logs instead of + # surfacing later as a missing file at a trust. + base = tmp_path / "base" + (base / "app").mkdir(parents=True) + (base / "app" / "client_app.py").write_text("x") + (base / "pyproject.toml").write_text("x") + (base / "operator_extra.py").write_text("x") + + with caplog.at_level(logging.DEBUG): + kept = fl_service.list_local_base_files(base, FLOWER_ROOT) + + assert kept == ["app/client_app.py", "pyproject.toml"] + assert "operator_extra.py" in caplog.text + + @patch("flip_api.fl_services.services.fl_service.JobRequiredFiles.is_valid_job_type", return_value=True) @patch("flip_api.fl_services.services.fl_service.verify_bundle_paths") @patch("flip_api.fl_services.services.fl_service.JobRequiredFiles.get_required_files") @@ -1803,7 +1971,9 @@ def test_bundle_nvflare_application_uploads_nested_base_paths( model_bucket = mocked_settings.SCANNED_MODEL_FILES_BUCKET dest_bucket = mocked_settings.FL_APP_DESTINATION_BUCKET - write_base_tree(base_dir, "nvflare", "standard", ["app/config/config_fed_server.json", "app/custom/sub/deep.py"]) + write_base_tree( + base_dir, "nvflare", "standard", ["app/config/config_fed_server.json", "app/custom/sub/deep.py", "meta.json"] + ) mock_client = mock_s3.return_value mock_client.get_object.return_value = { @@ -1840,7 +2010,7 @@ def test_bundle_nvflare_application_propagates_upload_failure( base_dir = mocked_settings.FL_APP_BASE_DIR model_bucket = mocked_settings.SCANNED_MODEL_FILES_BUCKET - write_base_tree(base_dir, "nvflare", "standard", ["app/file1.py"]) + write_base_tree(base_dir, "nvflare", "standard", ["app/file1.py", "meta.json"]) mock_client = mock_s3.return_value mock_client.get_object.return_value = {