Skip to content

refactor(fl): allowlist the app bundle — ship only app/ plus the backend's root file - #1008

Open
atriaybagur wants to merge 6 commits into
developfrom
fl-apps-bundle-excludes
Open

refactor(fl): allowlist the app bundle — ship only app/ plus the backend's root file#1008
atriaybagur wants to merge 6 commits into
developfrom
fl-apps-bundle-excludes

Conversation

@atriaybagur

@atriaybagur atriaybagur commented Aug 19, 2026

Copy link
Copy Markdown
Member

What

list_local_base_files walks FL_APP_BASE_DIR and mirrors it 1:1 into the app bucket, filtering nothing but symlinks. This replaces that with a positive allowlist: a bundle is everything under app/ plus one root file per backend, and nothing else.

BUNDLED_APP_DIR_NAME = "app"
BUNDLED_ROOT_FILES = {
    FLBackend.NVFLARE: frozenset({"meta.json"}),       # the job definition NVFLARE deploys from
    FLBackend.FLOWER:  frozenset({"pyproject.toml"}),  # the FAB definition
}

pyproject.toml must stay at the root because [tool.flwr.app.components] resolves app.server_app:app relative to it — flwr run . executes there.

Note: this PR previously proposed a denylist of known dev/tooling directories. It now inverts that. The history below explains why; the denylist commit is still in the branch, superseded by the allowlist commit on top.

Why a denylist wasn't enough

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 has to keep pace with every tool a developer might run, and one miss ships the artefact to every trust. The denylist this replaces already omitted .tox, .nox, htmlcov, .idea, dist/ and *.egg-info.

Allowlisting is closed by construction: a .venv is excluded because it is not app/, not because someone remembered to name it.

This is not hypothetical. It cost a full afternoon of e2e debugging on 2026-08-26. A .venv built against python3.14 sat in fl-apps/flower/{standard,evaluation}, was bundled to S3, and was downloaded into every Flower run directory — where the ServerApp found an interpreter-less venv, died instantly, and left [flwr-serverapp] <defunct>. The hub reported only status=INITIATED until timeout. Four consecutive runs failed this way before the cause was traced.

Note those .venv dirs are gitignored — and gitignored is not excluded from the bundler, which walks the filesystem, not git.

It also drops real files that don't belong at a trust

A denylist could never catch these, because they aren't artefacts:

file why it should not ship
recipe.py a developer driver that regenerates the committed configs. Its own docstring says "Run from the flip-utils venv (needs the full extra — flip + nvflare + torch)". It would otherwise sit dead in every trust's app directory as executable Python.
README.md documentation
required_files.json the per-template source for the backend-level manifest flip-api actually reads, one directory up at fl-apps/<backend>/required_files.json

Effect

template before after
nvflare/standard 6 files 3
flower/standard 6 files 4
flower/standard, with a developer's .venv 26 files 4

Design notes

  • __pycache__/*.pyc/*.pyo stay named explicitly. Compiled Python is the one artefact that appears inside app/, where position alone cannot exclude it. Pruned in place on dirnames during os.walk, so those trees are never descended into.
  • Uploads are untouched. The researcher's model files and any evaluation checkpoint are added by the bundler after this walk — into app*/custom/, or diverted to server_checkpoints/ so a large file does not collapse NVFLARE's app-deploy. They never pass through list_local_base_files.
  • Per-backend, resolved at the call site. Both bundlers already knew their backend, so this is a one-argument change at each.
  • Fails closed, but audibly. FL_APP_BASE_DIR may point at an operator-provided tree, where an allowlist drops files it does not recognise. Every excluded path is logged at debug level, and the "base application files missing" error now names the rule, so a visibly non-empty template directory reporting "missing" stays diagnosable.
  • .env.app is no longer preserved. The previous denylist avoided a "skip dotfiles" rule to protect it — but there are zero .env.app files under fl-apps; all 8 live in fl-tutorials, which this walk never touches. Even there it is a simulator-time file.

Behaviour change

A template with no app/ directory now yields no bundleable files at all, so it is rejected at the first check (Base application files missing …) rather than by the later app-folder scan. test_bundle_nvflare_application_no_app_folders is updated accordingly; the error text now names the rule and the debug log lists what was excluded.

Tests

flip-api/tests/unit/fl_services/200 passed. Nine cover this walk directly:

  • artefacts excluded without being enumerated (.venv, .ruff_cache, .DS_Store)
  • unknown tool output excluded (.tox, .nox, htmlcov, .idea, dist, *.egg-info) — the property a denylist cannot have
  • __pycache__ / .pyc / .pyo inside app/
  • recipe.py, README.md, required_files.json dropped
  • root file is per-backend, and the other backend's does not ride along
  • symlinks skipped, including a symlinked directory inside app/
  • the debug log names what it dropped

list_local_base_files walks FL_APP_BASE_DIR and mirrors it 1:1 into the app
bucket, filtering nothing but symlinks. In dev the repo's fl-apps/ tree is
bind-mounted into flip-api, so whatever a developer's tooling leaves inside a
template directory is bundled and uploaded to every trust.

It is not hypothetical: fl-apps/flower/{standard,evaluation} currently carry a
.venv and a .ruff_cache, and a live e2e smoke uploaded 35 files for the xray
app of which 19 were artefacts:

    .venv/pyvenv.cfg  .venv/.lock  .venv/bin/activate{,.bat,.csh,.fish,.nu,.ps1}
    .venv/lib/python3.12/site-packages/_virtualenv.{py,pth}
    .ruff_cache/CACHEDIR.TAG  .ruff_cache/0.14.7/...  ...

Prune a denylist of known dev/tooling directory names during the walk (in
place, so a virtualenv's file tree is never descended into), plus *.pyc/*.pyo
and .DS_Store. Against the current tree this takes flower/standard from 26
files to 6 and flower/evaluation from 24 to 5; nvflare/standard, which is
clean, is unchanged at 6.

Deliberately a denylist and not a 'skip dotfiles' rule: the tutorials ship a
real .env.app that must still reach the trusts. Covered by a test.

Production never saw this - the baked tree comes from a clean CI checkout - but
FL_APP_BASE_DIR is documented as overridable to an operator-provided tree, which
has the same exposure as dev.

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@atriaybagur

Copy link
Copy Markdown
Member Author

Both inline comments addressed in eae004f: each bundling path now verifies the backend's root file (meta.json / pyproject.toml) is actually present in the walked template, after the existing empty-list guard, and fails with a FileNotFoundError naming the missing file and the template dir. Unit tests added for both backends; flip-api ruff + mypy + unit suite green (1582 passed).

…son guard

CI tests the PR merge ref, where develop's fail-loudly missing-config.json
check (#1056) runs before the base-template walk: the new missing-root-file
tests now supply config.json (with is_valid_job_type patched, as every
config-bearing bundler test does), and develop's empty-manifest test gets
the meta.json its base tree needs to reach the manifest check it pins.

Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
skipped.append(entry.name)
continue
if entry.is_dir():
if entry.name != BUNDLED_APP_DIR_NAME:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hardcodes recursion into a directory literally named app, but bundle_nvflare_application's own downstream comment a few dozen lines below (Find app folders (top-level directories that start with app, e.g. app, app_site1, etc), around line 685) and fl-services/nvflare/fl-api-base/fl_api/utils/upload.py both document app_site-1/app_site-2 as a supported multi-site base-template layout. With this change, any such folder in a local base template -- baked-in or an operator-provided FL_APP_BASE_DIR override -- is silently excluded (only a DEBUG log records it), with no test covering the scenario. Was retiring multi-site base templates intentional? If so it should be called out explicitly and the stale docs/comments updated; if not, the allowlist needs to recognize app_site-/app_ directories too.

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)}")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring promises every skipped path is logged at debug level, but a symlinked directory nested inside app/ is excluded via os.walk(followlinks=False) without ever being appended to skipped in _walk_app_dir -- unlike a symlinked directory at the template root, which is logged. Minor inconsistency between the documented guarantee and actual behavior for this one nested case.

@garciadias garciadias left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The allowlist rework is a well-reasoned, well-tested fix for a real production incident (dev/tooling artefacts shipped to trusts). However it silently and untestedly narrows a separate, documented capability: NVFLARE's multi-site app_site-N base-template folders (still referenced in this same file's bundle_nvflare_application comments and in fl-api-base/fl_api/utils/upload.py) are no longer picked up by list_local_base_files, with only a DEBUG log recording the omission. Please either widen the allowlist to recognize app_site-/app_ folders, or explicitly retire that capability with updated docs/comments and a test proving the new intentional behavior. Two additional minor nits left inline.

@garciadias

Copy link
Copy Markdown
Collaborator

One more finding that GitHub wouldn't let me anchor as an inline comment (the line falls outside this PR's diff hunks) — noting it here instead:

flip-api/src/flip_api/fl_services/services/fl_service.py:692 (Minor) — the "No app folders found under base application" branch is now unreachable for the scenarios the new tests exercise (both are caught earlier by the two new guards this PR adds). It's still reachable for "root file present, app/ directory entirely absent," but that case isn't tested, and its wording is now inconsistent with the clearer, rule-naming errors added elsewhere in this PR. Suggested: add a unit test for the "root file only, no app/" case, or fold it into the new guards so all three malformed-template cases share consistent wording.

@garciadias garciadias assigned atriaybagur and unassigned garciadias Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants