Skip to content

Commit f2aac8a

Browse files
authored
update docs and use extension prefix for app extensions (#3088)
This mostly contains doc updates for creating app extensions with one fix so app extensions use the extension prefix config instead of hardcoding `extensions`. Co-authored-by: jasquat <jasquat@users.noreply.github.com>
1 parent dffd57a commit f2aac8a

5 files changed

Lines changed: 117 additions & 14 deletions

File tree

‎docs/explanation/dev/app_extensions.md‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,21 @@ repository at application boot:
77
SPIFFWORKFLOW_BACKEND_APP_EXTENSIONS='["example"]'
88
```
99

10-
Arena loads `extensions/<name>/__init__.py` beneath
11-
`SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR` and calls `init_app(app)` after
12-
initializing the database integration. Packages support relative imports.
10+
Arena loads `<prefix>/<name>/__init__.py` beneath
11+
`SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR`, where `<prefix>` is
12+
`SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX` (default: `extensions`).
13+
It calls `init_app(app)` after initializing the database integration. Packages
14+
support relative imports. Install any additional dependencies in the deployment
15+
environment.
16+
17+
Python app extensions share this directory prefix with BPMN/UI extensions, but
18+
have their own explicit enable list above; they do not require
19+
`SPIFFWORKFLOW_BACKEND_EXTENSIONS_API_ENABLED`. For example, setting the prefix to
20+
`custom/extensions` loads `custom/extensions/example/__init__.py`, not
21+
`extensions/example/__init__.py`. There is no fallback to the default directory.
22+
The prefix must be a nonempty repository-relative directory, not the repository
23+
root. Absolute paths, `..` traversal, and symlinks escaping the repository are
24+
rejected when Python app extensions are enabled.
1325

1426
```python
1527
# extensions/example/__init__.py
@@ -28,6 +40,32 @@ identifiers, and package paths must stay within the configured repository.
2840
Packages receive private module identities to isolate relative imports between
2941
app instances.
3042

43+
## Initialization lifecycle and ordering
44+
45+
The app factory calls `setup_config`, `db.init_app`, `migrate.init_app`, then
46+
`init_app_extensions`, in that order. The database engine is available at this
47+
point, but no connection is opened or schema migration run by those initialization
48+
calls. Access Flask context-dependent APIs inside `with app.app_context():`.
49+
Database URI and engine options have already been consumed; an initializer cannot
50+
reconfigure the existing engine by changing those settings.
51+
52+
Initializers run in configured list order, with no automatic dependency sorting.
53+
If one initializer depends on another's registrations, list its dependency first.
54+
Keep module imports free of side effects and use `init_app(app)` to register the
55+
extension's hooks and providers.
56+
57+
This is an early registration hook, not an "application fully ready" hook.
58+
Celery, the background-job publisher, scheduler, and some logging/error handling
59+
services are initialized afterward. Do not assume they are available, or start
60+
background work that depends on unfinished setup. Initializers can also run
61+
before worker processes fork; take care with process-local resources.
62+
63+
API processes, workers, and Flask CLI commands all create apps. Initializers must
64+
therefore account for multiple app instances and command-line use, including
65+
schema migration commands. Document each extension's dependencies, configuration,
66+
setup/migration requirements, and operational behavior alongside its package in
67+
the process-model repository.
68+
3169
## Model source providers
3270

3371
An extension can register a provider at `app.extensions["model_sources"]` to

‎docs/how_to_guides/deployment/deploy.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ You do not need container-specific Celery env var differences for this split; `.
4242

4343
## Configuration
4444

45-
The app uses sqlalchemy and supports mysql, postgres, or sqlite.
46-
You must set `SPIFFWORKFLOW_BACKEND_DATABASE_TYPE` to one of these three values as well as setting `SPIFFWORKFLOW_BACKEND_DATABASE_URI`.
45+
The app uses SQLAlchemy and supports MySQL, PostgreSQL, or SQLite.
46+
Set `SPIFFWORKFLOW_BACKEND_DATABASE_URI` to the SQLAlchemy connection URL; Arena
47+
infers the database type from that URL. `SPIFFWORKFLOW_BACKEND_DATABASE_TYPE`
48+
selects the default connection configuration when no URI is supplied.
4749
Check the `default.py` config file in the `spiffworkflow-backend/src/spiffworkflow_backend/config` directory for more env vars.
50+
51+
For trusted Python initialization code, see
52+
[Python app extensions](/explanation/dev/app_extensions). Configuration and
53+
operational instructions for individual extensions belong with their packages
54+
in the process-model repository.

‎docs/how_to_guides/extend_the_system/extensions.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ This variable activates the extensions feature in the SpiffWorkflow backend:
2323

2424
By default, Spiff Arena will look for extensions in `[SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR]/extensions`, but that can be configured using `SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX`.
2525

26+
[Python app extensions](/explanation/dev/app_extensions) use the same configured
27+
directory prefix, but are enabled separately with
28+
`SPIFFWORKFLOW_BACKEND_APP_EXTENSIONS`. They run trusted Python initialization
29+
code at application boot, rather than BPMN workflows through the extensions API.
30+
2631
### Creating an Extension
2732

2833
After enabling extensions from the backend, you can create extensions in the Spiff Arena frontend.

‎spiffworkflow-backend/src/spiffworkflow_backend/services/app_extensions.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,16 @@ def init_app_extensions(app: Flask) -> None:
2424
if not names:
2525
return
2626
root = Path(app.config["SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR"]).resolve()
27+
prefix = app.config.get("SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX", "extensions")
28+
if not isinstance(prefix, str) or not prefix.strip() or Path(prefix).is_absolute() or ".." in Path(prefix).parts:
29+
raise ValueError("SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX must be a repository-relative directory")
30+
extensions_root = (root / prefix).resolve()
31+
if extensions_root == root or not extensions_root.is_relative_to(root):
32+
raise ValueError("Application extension directory escapes its repository or is the repository root")
2733
for name in names:
28-
package = (root / "extensions" / name).resolve()
34+
package = (extensions_root / name).resolve()
2935
source = (package / "__init__.py").resolve()
30-
if not package.is_relative_to(root / "extensions") or not source.is_relative_to(package):
36+
if not package.is_relative_to(extensions_root) or not source.is_relative_to(package):
3137
raise ValueError(f"Application extension escapes its repository package: {name!r}")
3238
if not source.is_file():
3339
raise FileNotFoundError(f"Application extension initializer not found: {source}")

‎spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_app_extensions.py‎

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,18 @@
77
from spiffworkflow_backend.services.app_extensions import init_app_extensions
88

99

10-
def make_extension(root: Path, name: str, source: str) -> Path:
11-
package = root / "extensions" / name
10+
def make_extension(root: Path, name: str, source: str, *, prefix: str = "extensions") -> Path:
11+
package = root / prefix / name
1212
package.mkdir(parents=True)
1313
(package / "__init__.py").write_text(source)
1414
return package
1515

1616

17-
def configured_app(root: Path, names: object) -> Flask:
17+
def configured_app(root: Path, names: object, *, prefix: object = "extensions") -> Flask:
1818
app = Flask(__name__)
1919
app.config["SPIFFWORKFLOW_BACKEND_BPMN_SPEC_ABSOLUTE_DIR"] = str(root)
2020
app.config["SPIFFWORKFLOW_BACKEND_APP_EXTENSIONS"] = names
21+
app.config["SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX"] = prefix
2122
return app
2223

2324

@@ -55,21 +56,67 @@ def test_missing_package_fails(tmp_path: Path) -> None:
5556
init_app_extensions(configured_app(tmp_path, ["missing"]))
5657

5758

59+
@pytest.mark.parametrize("prefix", ["extensions", "plugins", "company/plugins"])
5860
@pytest.mark.parametrize("escape", ["directory", "initializer"])
59-
def test_symlink_escape_fails(tmp_path: Path, escape: str) -> None:
61+
def test_symlink_escape_fails(tmp_path: Path, escape: str, prefix: str) -> None:
6062
outside = tmp_path / "outside"
6163
outside.mkdir()
6264
(outside / "__init__.py").write_text("raise AssertionError('must not run')\n")
6365
root = tmp_path / "models"
64-
(root / "extensions").mkdir(parents=True)
65-
package = root / "extensions" / "escape"
66+
(root / prefix).mkdir(parents=True)
67+
package = root / prefix / "escape"
6668
if escape == "directory":
6769
package.symlink_to(outside, target_is_directory=True)
6870
else:
6971
package.mkdir()
7072
(package / "__init__.py").symlink_to(outside / "__init__.py")
7173
with pytest.raises(ValueError, match="escapes"):
72-
init_app_extensions(configured_app(root, ["escape"]))
74+
init_app_extensions(configured_app(root, ["escape"], prefix=prefix))
75+
76+
77+
@pytest.mark.parametrize("prefix", ["plugins", "company/plugins"])
78+
def test_custom_prefix_loads_only_configured_directory(tmp_path: Path, prefix: str) -> None:
79+
make_extension(tmp_path, "example", "raise AssertionError('must not load default directory')\n")
80+
package = make_extension(
81+
tmp_path, "example", "from .helper import VALUE\ndef init_app(app):\n app.extensions['value'] = VALUE\n", prefix=prefix
82+
)
83+
(package / "helper.py").write_text("VALUE = 'custom'\n")
84+
app = configured_app(tmp_path, ["example"], prefix=prefix)
85+
init_app_extensions(app)
86+
assert app.extensions["value"] == "custom"
87+
88+
89+
def test_default_prefix_when_setting_absent(tmp_path: Path) -> None:
90+
make_extension(tmp_path, "example", "def init_app(app):\n app.extensions['loaded'] = True\n")
91+
app = configured_app(tmp_path, ["example"])
92+
app.config.pop("SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX")
93+
init_app_extensions(app)
94+
assert app.extensions["loaded"] is True
95+
96+
97+
def test_missing_custom_package_does_not_fall_back(tmp_path: Path) -> None:
98+
make_extension(tmp_path, "example", "raise AssertionError('must not fall back')\n")
99+
with pytest.raises(FileNotFoundError, match="plugins"):
100+
init_app_extensions(configured_app(tmp_path, ["example"], prefix="plugins"))
101+
102+
103+
@pytest.mark.parametrize("prefix", ["", " ", None, 42, "/outside", "../outside", "plugins/../extensions", ".", "./"])
104+
def test_invalid_prefix_fails(tmp_path: Path, prefix: object) -> None:
105+
make_extension(tmp_path, "example", "raise AssertionError('must not run')\n")
106+
with pytest.raises(ValueError):
107+
init_app_extensions(configured_app(tmp_path, ["example"], prefix=prefix))
108+
109+
110+
@pytest.mark.parametrize("prefix", ["extensions", "plugins", "company/plugins"])
111+
def test_prefix_symlink_cannot_escape_repository(tmp_path: Path, prefix: str) -> None:
112+
root = tmp_path / "models"
113+
outside = tmp_path / "outside"
114+
make_extension(outside, "example", "raise AssertionError('must not run')\n", prefix="packages")
115+
directory = root / prefix
116+
directory.parent.mkdir(parents=True)
117+
directory.symlink_to(outside / "packages", target_is_directory=True)
118+
with pytest.raises(ValueError, match="escapes"):
119+
init_app_extensions(configured_app(root, ["example"], prefix=prefix))
73120

74121

75122
def test_repositories_with_same_name_are_isolated(tmp_path: Path) -> None:

0 commit comments

Comments
 (0)