appmanager-server 0.2.1__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- appmanager_server-0.3.1/CHANGELOG.md +59 -0
- appmanager_server-0.3.1/MANIFEST.in +17 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/PKG-INFO +7 -2
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/README.md +5 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/__init__.py +1 -1
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/admin/app_installer.py +367 -25
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/admin/routes.py +131 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/api/routes.py +23 -11
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/auth/routes.py +2 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/cli.py +182 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/config.py +27 -2
- appmanager_server-0.3.1/appmanager/dependency_manager.py +442 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/extension.py +23 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/middleware.py +38 -4
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/security.py +19 -4
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/signals.py +1 -0
- appmanager_server-0.3.1/appmanager/templates/admin/app_detail.html +276 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/admin/dashboard.html +197 -69
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager_server.egg-info/PKG-INFO +7 -2
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager_server.egg-info/SOURCES.txt +4 -1
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/pyproject.toml +2 -2
- appmanager_server-0.3.1/tests/test_app_updates.py +136 -0
- appmanager_server-0.3.1/tests/test_dependency_manager.py +139 -0
- appmanager_server-0.3.1/tests/test_nested_portal.py +123 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_packaging_and_cli.py +37 -2
- appmanager_server-0.2.1/.env.example +0 -51
- appmanager_server-0.2.1/CHANGELOG.md +0 -31
- appmanager_server-0.2.1/MANIFEST.in +0 -11
- appmanager_server-0.2.1/appmanager/templates/admin/app_detail.html +0 -136
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/LICENSE +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/SECURITY.md +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/admin/__init__.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/admin/members.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/admin/registry.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/api/__init__.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/app_config.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/auth/__init__.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/auth/oauth.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/auth/utils.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/bridge.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/database.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/extensions.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/health.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/hooks.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/models.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/py.typed +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/sdk.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/security_scanner.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/css/app.css +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/fonts.css +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/htmx/htmx.min.js +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/admin/_member_drawer.html +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/admin/_members_table.html +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/admin/flairs.html +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/admin/permissions.html +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/admin/roles.html +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/admin/users.html +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/auth/login.html +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/auth/profile.html +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/base.html +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/errors/401.html +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/errors/403.html +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/errors/404.html +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/errors/500.html +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/index.html +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager_server.egg-info/dependency_links.txt +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager_server.egg-info/entry_points.txt +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager_server.egg-info/requires.txt +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager_server.egg-info/top_level.txt +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/requirements.txt +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/setup.cfg +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_api.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_app_settings.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_app_settings_config.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_auth.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_cli_dev_and_templates.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_cli_health.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_cli_scaffolding.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_error_pages.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_extension_apps.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_extension_pattern.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_hooks.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_installer.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_invalid_install_failures.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_manifest_sdk.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_middleware.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_role_management.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_sdk.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_security_hardening.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_security_precheck_flow.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_security_scanner.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_signals.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_user_elevation_and_visibility.py +0 -0
- {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_user_tracking.py +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to **`appmanager-server`** will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## [0.3.1] - 2026-08-28
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **App & Role Deletion Type-To-Confirm Modal**: Fixed modal validation logic and dynamic form URL in Admin Dashboard where typing the exact app/role slug did not enable the confirmation submission.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## [0.3.0] - 2026-08-28
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- **Dependency & Environment Conflict Engine (`appmanager.dependency_manager`)**:
|
|
21
|
+
- Python runtime version verification comparing manifest `requires_python` against `platform.python_version()`.
|
|
22
|
+
- PEP 508 requirement analyzer checking active environment distributions for satisfied packages, packages to install, and conflicts against core host frameworks (`Flask`, `SQLAlchemy`, etc.).
|
|
23
|
+
- Shared dependency preservation analyzer protecting packages needed by other sub-apps when uninstalling.
|
|
24
|
+
- **Configurable Virtual Environment Modes (`APP_VENV_MODE`)**:
|
|
25
|
+
- `"singular"` (Default): Single shared host environment.
|
|
26
|
+
- `"isolated"`: Per-app isolated virtual environment (`.venv/`).
|
|
27
|
+
- **One-Click Sub-App Updates & Replacements**:
|
|
28
|
+
- Git Update action (`POST /admin/apps/<id>/update-git`) pulling remote changes with automated AST security scan, dependency sync, and cache reload.
|
|
29
|
+
- In-place ZIP replacement (`POST /admin/apps/<id>/precheck-replace-zip` -> `confirm-replace-zip`) with atomic rollback protection.
|
|
30
|
+
- **CLI Commands**:
|
|
31
|
+
- `appmanager update <slug> [--zip <path>]`
|
|
32
|
+
- `appmanager check-deps [slug] [--all] [--install]`
|
|
33
|
+
- `appmanager install-deps [slug] [--all]`
|
|
34
|
+
- **Nested AppManager Portals**: Support for nesting AppManager instances as sub-apps within parent AppManager instances with recursive dispatching.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## [0.2.1] - 2026-08-28
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
- **Configurable Dev Magic Login (`ALLOW_DEV_MAGIC_LOGIN`)**: Prevents showing one-click developer login buttons in production when SMTP is unconfigured unless explicitly permitted.
|
|
42
|
+
- **Configurable Admin Role Provisioning (`ADMIN_EMAILS` & `FIRST_USER_IS_ADMIN`)**:
|
|
43
|
+
- `ADMIN_EMAILS`: Specify comma-separated list of admin email addresses.
|
|
44
|
+
- `FIRST_USER_IS_ADMIN`: Controls whether the very first registered user in a blank database receives the admin role (defaults to `true`).
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
- Improved error messaging on `/auth/login` when SMTP is unconfigured.
|
|
48
|
+
- Expanded authentication test suite.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## [0.2.0] - 2026-08-28
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
- Initial PyPI release under package name **`appmanager-server`**.
|
|
56
|
+
- CLI commands and aliases: `appmanager-server`, `appmgr-server`, `appmanager`, and `appmgr`.
|
|
57
|
+
- AST Security Scanner (`appmanager.security_scanner.SecurityScanner`) for ZIP and Git package pre-installation checks.
|
|
58
|
+
- Python 3.10-3.13 support.
|
|
59
|
+
- PyPI Trusted Publishing via GitHub Actions OIDC.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
include LICENSE
|
|
2
|
+
include README.md
|
|
3
|
+
include CHANGELOG.md
|
|
4
|
+
include SECURITY.md
|
|
5
|
+
include requirements.txt
|
|
6
|
+
include .env.example
|
|
7
|
+
graft appmanager/templates
|
|
8
|
+
graft appmanager/static
|
|
9
|
+
global-exclude __pycache__
|
|
10
|
+
global-exclude *.py[cod]
|
|
11
|
+
global-exclude .env*
|
|
12
|
+
global-exclude *.db
|
|
13
|
+
global-exclude *.sqlite*
|
|
14
|
+
prune instance
|
|
15
|
+
prune installed_apps
|
|
16
|
+
prune .venv
|
|
17
|
+
prune .pytest_cache
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: appmanager-server
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: High-performance multi-tenant WSGI portal, dynamic sub-app dispatcher, and extension framework for Python/Flask.
|
|
5
5
|
Author: AppManager Contributors
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/r3sbarra/appmanager-server
|
|
@@ -282,6 +282,11 @@ Available UI slots:
|
|
|
282
282
|
| `appmanager dev <slug>` | Run standalone local test server with mock authentication |
|
|
283
283
|
| `appmanager seed` | Seed database with default starter apps, roles, and flairs |
|
|
284
284
|
| `appmanager new-subapp <name>` | Scaffold a sub-app with templates (`basic`, `api`, `extension`, `htmx`, `full`) |
|
|
285
|
+
| `appmanager install-git <url>` | Install a sub-app from Git repository with security pre-check audit |
|
|
286
|
+
| `appmanager install-zip <path>` | Install a sub-app from ZIP package with automated security pre-check |
|
|
287
|
+
| `appmanager update <slug>` | One-click update for Git sub-apps or in-place replacement via `--zip <path>` |
|
|
288
|
+
| `appmanager check-deps [slug]` | Inspect dependency health, Python version compatibility, and conflicts (`--all`, `--install`) |
|
|
289
|
+
| `appmanager install-deps [slug]` | Automatically install declared requirements into single or isolated venv (`--all`) |
|
|
285
290
|
| `appmanager validate-subapp <path>` | Validate a sub-app folder or ZIP package against manifest rules |
|
|
286
291
|
| `appmanager export-app <slug>` | Package an installed sub-app into a deployable ZIP archive |
|
|
287
292
|
| `appmanager reload-app <slug>` | Invalidate in-memory WSGI cache for zero-downtime updates |
|
|
@@ -221,6 +221,11 @@ Available UI slots:
|
|
|
221
221
|
| `appmanager dev <slug>` | Run standalone local test server with mock authentication |
|
|
222
222
|
| `appmanager seed` | Seed database with default starter apps, roles, and flairs |
|
|
223
223
|
| `appmanager new-subapp <name>` | Scaffold a sub-app with templates (`basic`, `api`, `extension`, `htmx`, `full`) |
|
|
224
|
+
| `appmanager install-git <url>` | Install a sub-app from Git repository with security pre-check audit |
|
|
225
|
+
| `appmanager install-zip <path>` | Install a sub-app from ZIP package with automated security pre-check |
|
|
226
|
+
| `appmanager update <slug>` | One-click update for Git sub-apps or in-place replacement via `--zip <path>` |
|
|
227
|
+
| `appmanager check-deps [slug]` | Inspect dependency health, Python version compatibility, and conflicts (`--all`, `--install`) |
|
|
228
|
+
| `appmanager install-deps [slug]` | Automatically install declared requirements into single or isolated venv (`--all`) |
|
|
224
229
|
| `appmanager validate-subapp <path>` | Validate a sub-app folder or ZIP package against manifest rules |
|
|
225
230
|
| `appmanager export-app <slug>` | Package an installed sub-app into a deployable ZIP archive |
|
|
226
231
|
| `appmanager reload-app <slug>` | Invalidate in-memory WSGI cache for zero-downtime updates |
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import ast
|
|
2
2
|
import importlib.util
|
|
3
|
+
import inspect
|
|
3
4
|
import json
|
|
5
|
+
import logging
|
|
4
6
|
import os
|
|
5
7
|
import shutil
|
|
6
8
|
import subprocess
|
|
@@ -14,6 +16,12 @@ from flask import current_app
|
|
|
14
16
|
from werkzeug.utils import secure_filename
|
|
15
17
|
|
|
16
18
|
from appmanager.database import db
|
|
19
|
+
from appmanager.dependency_manager import (
|
|
20
|
+
DependencyAnalysisReport,
|
|
21
|
+
analyze_dependencies,
|
|
22
|
+
analyze_uninstall_dependencies,
|
|
23
|
+
install_app_dependencies,
|
|
24
|
+
)
|
|
17
25
|
from appmanager.models import InstalledApp, User, UserAppPermission
|
|
18
26
|
from appmanager.security import (
|
|
19
27
|
extract_zip_safely,
|
|
@@ -21,7 +29,9 @@ from appmanager.security import (
|
|
|
21
29
|
validate_entrypoint_path,
|
|
22
30
|
)
|
|
23
31
|
from appmanager.security_scanner import SecurityScanReport, run_security_scan
|
|
24
|
-
from appmanager.signals import subapp_installed, subapp_uninstalled
|
|
32
|
+
from appmanager.signals import subapp_installed, subapp_reloaded, subapp_uninstalled, subapp_updated
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("appmanager.installer")
|
|
25
35
|
|
|
26
36
|
|
|
27
37
|
def sanitize_slug(slug_text: str) -> str:
|
|
@@ -210,32 +220,51 @@ def validate_subapp_package(path_or_zip: str) -> Tuple[bool, List[str], Dict[str
|
|
|
210
220
|
|
|
211
221
|
def discover_entrypoint(app_dir: str) -> str:
|
|
212
222
|
"""
|
|
213
|
-
Search for common Flask entrypoint files (app.py, wsgi.py, main.py, run.py
|
|
214
|
-
Returns 'module_name:app_variable' string e.g. 'app:app'
|
|
223
|
+
Search for common Flask entrypoint files (app.py, wsgi.py, main.py, run.py, src/app.py, packages)
|
|
224
|
+
or manifest.json. Returns 'module_name:app_variable' string e.g. 'app:app'
|
|
215
225
|
"""
|
|
216
226
|
manifest = parse_manifest(app_dir)
|
|
217
227
|
if manifest and "entry_point" in manifest:
|
|
218
228
|
return manifest["entry_point"]
|
|
219
229
|
|
|
220
|
-
candidates = [
|
|
230
|
+
candidates = [
|
|
231
|
+
"app.py",
|
|
232
|
+
"wsgi.py",
|
|
233
|
+
"main.py",
|
|
234
|
+
"run.py",
|
|
235
|
+
"server.py",
|
|
236
|
+
os.path.join("src", "app.py"),
|
|
237
|
+
os.path.join("src", "main.py"),
|
|
238
|
+
os.path.join("src", "wsgi.py"),
|
|
239
|
+
]
|
|
221
240
|
for candidate in candidates:
|
|
222
241
|
if os.path.exists(os.path.join(app_dir, candidate)):
|
|
223
|
-
module_name = candidate[:-3] # Strip .py
|
|
242
|
+
module_name = candidate[:-3].replace(os.sep, ".") # Strip .py and normalize slashes
|
|
224
243
|
return f"{module_name}:app"
|
|
225
244
|
|
|
245
|
+
# Check for package directories with __init__.py (e.g. appmanager, app, <slug>)
|
|
246
|
+
slug = os.path.basename(app_dir.rstrip("/\\"))
|
|
247
|
+
for pkg_cand in [slug, slug.replace("-", "_"), "app", "appmanager"]:
|
|
248
|
+
init_file = os.path.join(app_dir, pkg_cand, "__init__.py")
|
|
249
|
+
if os.path.exists(init_file):
|
|
250
|
+
return f"{pkg_cand}:create_app"
|
|
251
|
+
|
|
226
252
|
# Default fallback
|
|
227
253
|
return "app:app"
|
|
228
254
|
|
|
229
255
|
|
|
230
|
-
def install_dependencies(app_dir: str) ->
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
256
|
+
def install_dependencies(app_dir: str) -> Tuple[bool, str]:
|
|
257
|
+
"""
|
|
258
|
+
Installs sub-app dependencies using the configured virtual environment mode (singular or isolated).
|
|
259
|
+
"""
|
|
260
|
+
venv_mode = "singular"
|
|
261
|
+
if current_app:
|
|
262
|
+
venv_mode = current_app.config.get("APP_VENV_MODE", "singular")
|
|
263
|
+
|
|
264
|
+
ok, msg = install_app_dependencies(app_dir, venv_mode=venv_mode)
|
|
265
|
+
if not ok:
|
|
266
|
+
print(f"[INSTALLER WARNING] {msg}")
|
|
267
|
+
return ok, msg
|
|
239
268
|
|
|
240
269
|
|
|
241
270
|
def load_wsgi_app_from_path(app_dir: str, entry_point: str = "app:app") -> Any:
|
|
@@ -253,7 +282,9 @@ def load_wsgi_app_from_path(app_dir: str, entry_point: str = "app:app") -> Any:
|
|
|
253
282
|
|
|
254
283
|
module_path = module_path_or_err
|
|
255
284
|
if not os.path.exists(module_path):
|
|
256
|
-
raise FileNotFoundError(
|
|
285
|
+
raise FileNotFoundError(
|
|
286
|
+
f"Entrypoint module/file '{module_name}' was not found in sub-app package."
|
|
287
|
+
)
|
|
257
288
|
|
|
258
289
|
# Unique scoped module spec name to avoid sys.path pollution
|
|
259
290
|
slug = os.path.basename(app_dir)
|
|
@@ -267,15 +298,37 @@ def load_wsgi_app_from_path(app_dir: str, entry_point: str = "app:app") -> Any:
|
|
|
267
298
|
sys.modules[scoped_module_name] = mod
|
|
268
299
|
spec.loader.exec_module(mod)
|
|
269
300
|
|
|
270
|
-
# Check for Flask app instance
|
|
301
|
+
# Check for Flask app instance, callable create_app factory, or WSGI middleware
|
|
271
302
|
if hasattr(mod, app_var_name):
|
|
272
303
|
app_obj = getattr(mod, app_var_name)
|
|
273
|
-
if
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
304
|
+
if hasattr(app_obj, "wsgi_app"):
|
|
305
|
+
return app_obj
|
|
306
|
+
|
|
307
|
+
if callable(app_obj):
|
|
308
|
+
if inspect.isfunction(app_obj) or inspect.ismethod(app_obj):
|
|
309
|
+
try:
|
|
310
|
+
sig = inspect.signature(app_obj)
|
|
311
|
+
params = list(sig.parameters.values())
|
|
312
|
+
# Standard WSGI callable: (environ, start_response)
|
|
313
|
+
if len(params) == 2 and params[0].name in ("environ", "env"):
|
|
314
|
+
return app_obj
|
|
315
|
+
required_params = [
|
|
316
|
+
p
|
|
317
|
+
for p in params
|
|
318
|
+
if p.default == inspect.Parameter.empty
|
|
319
|
+
and p.kind
|
|
320
|
+
in (
|
|
321
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
322
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
323
|
+
)
|
|
324
|
+
]
|
|
325
|
+
if len(required_params) == 0:
|
|
326
|
+
return app_obj()
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
return app_obj
|
|
330
|
+
|
|
331
|
+
return app_obj
|
|
279
332
|
|
|
280
333
|
raise AttributeError(f"Module '{module_name}' in {app_dir} does not export '{app_var_name}'")
|
|
281
334
|
|
|
@@ -289,10 +342,14 @@ def _write_staging_metadata(staging_dir: str, meta: Dict[str, Any]) -> None:
|
|
|
289
342
|
"""Saves staging session metadata to disk inside the sandboxed staging directory."""
|
|
290
343
|
meta_path = os.path.join(staging_dir, "_staging_meta.json")
|
|
291
344
|
try:
|
|
292
|
-
# Don't serialize scan_report object directly to JSON if it's an object
|
|
345
|
+
# Don't serialize scan_report / dependency_report object directly to JSON if it's an object
|
|
293
346
|
serializable = dict(meta)
|
|
294
347
|
if "scan_report" in serializable and hasattr(serializable["scan_report"], "to_dict"):
|
|
295
348
|
serializable["scan_report"] = serializable["scan_report"].to_dict()
|
|
349
|
+
if "dependency_report" in serializable and hasattr(
|
|
350
|
+
serializable["dependency_report"], "to_dict"
|
|
351
|
+
):
|
|
352
|
+
serializable["dependency_report"] = serializable["dependency_report"].to_dict()
|
|
296
353
|
with open(meta_path, "w", encoding="utf-8") as f:
|
|
297
354
|
json.dump(serializable, f)
|
|
298
355
|
except Exception as e:
|
|
@@ -366,6 +423,7 @@ def stage_git_repo(
|
|
|
366
423
|
if not is_safe_repo_url(repo_url):
|
|
367
424
|
raise ValueError(f"Invalid or unsafe git repository URL: '{repo_url}'")
|
|
368
425
|
|
|
426
|
+
logger.info("Staging Git repository installation from '%s'", repo_url)
|
|
369
427
|
cleanup_expired_staged_sessions()
|
|
370
428
|
|
|
371
429
|
import tempfile
|
|
@@ -390,6 +448,7 @@ def stage_git_repo(
|
|
|
390
448
|
res.stderr.strip() or f"Failed to clone repository from '{repo_url}'."
|
|
391
449
|
)
|
|
392
450
|
except Exception as git_err:
|
|
451
|
+
logger.error("Failed to clone Git repository '%s': %s", repo_url, git_err)
|
|
393
452
|
if os.path.exists(temp_stage_dir):
|
|
394
453
|
shutil.rmtree(temp_stage_dir, ignore_errors=True)
|
|
395
454
|
raise ValueError(f"Failed to clone Git repository: {git_err}")
|
|
@@ -417,6 +476,10 @@ def stage_git_repo(
|
|
|
417
476
|
# Run static security pre-check
|
|
418
477
|
scan_report = run_security_scan(temp_stage_dir)
|
|
419
478
|
|
|
479
|
+
# Run dependency analysis
|
|
480
|
+
venv_mode = current_app.config.get("APP_VENV_MODE", "singular") if current_app else "singular"
|
|
481
|
+
dep_report = analyze_dependencies(temp_stage_dir, manifest=manifest, venv_mode=venv_mode)
|
|
482
|
+
|
|
420
483
|
_staged_installations[staging_id] = {
|
|
421
484
|
"staging_id": staging_id,
|
|
422
485
|
"staging_dir": temp_stage_dir,
|
|
@@ -427,9 +490,17 @@ def stage_git_repo(
|
|
|
427
490
|
"entry_point": resolved_entry_point,
|
|
428
491
|
"manifest": manifest,
|
|
429
492
|
"scan_report": scan_report,
|
|
493
|
+
"dependency_report": dep_report,
|
|
430
494
|
"created_at": time.time(),
|
|
431
495
|
}
|
|
432
496
|
_write_staging_metadata(temp_stage_dir, _staged_installations[staging_id])
|
|
497
|
+
logger.info(
|
|
498
|
+
"Staged Git app '%s' (staging_id=%s, risk=%s, safe=%s)",
|
|
499
|
+
resolved_slug,
|
|
500
|
+
staging_id,
|
|
501
|
+
scan_report.risk_level,
|
|
502
|
+
scan_report.is_safe,
|
|
503
|
+
)
|
|
433
504
|
|
|
434
505
|
return staging_id, scan_report, manifest
|
|
435
506
|
|
|
@@ -494,6 +565,10 @@ def stage_zip_file(
|
|
|
494
565
|
# Run static security pre-check
|
|
495
566
|
scan_report = run_security_scan(temp_stage_dir)
|
|
496
567
|
|
|
568
|
+
# Run dependency analysis
|
|
569
|
+
venv_mode = current_app.config.get("APP_VENV_MODE", "singular") if current_app else "singular"
|
|
570
|
+
dep_report = analyze_dependencies(temp_stage_dir, manifest=manifest, venv_mode=venv_mode)
|
|
571
|
+
|
|
497
572
|
_staged_installations[staging_id] = {
|
|
498
573
|
"staging_id": staging_id,
|
|
499
574
|
"staging_dir": temp_stage_dir,
|
|
@@ -504,9 +579,17 @@ def stage_zip_file(
|
|
|
504
579
|
"entry_point": resolved_entry_point,
|
|
505
580
|
"manifest": manifest,
|
|
506
581
|
"scan_report": scan_report,
|
|
582
|
+
"dependency_report": dep_report,
|
|
507
583
|
"created_at": time.time(),
|
|
508
584
|
}
|
|
509
585
|
_write_staging_metadata(temp_stage_dir, _staged_installations[staging_id])
|
|
586
|
+
logger.info(
|
|
587
|
+
"Staged ZIP package '%s' (filename: %s, risk=%s, safe=%s)",
|
|
588
|
+
resolved_slug,
|
|
589
|
+
filename,
|
|
590
|
+
scan_report.risk_level,
|
|
591
|
+
scan_report.is_safe,
|
|
592
|
+
)
|
|
510
593
|
|
|
511
594
|
return staging_id, scan_report, manifest
|
|
512
595
|
|
|
@@ -535,6 +618,7 @@ def finalize_staged_installation(
|
|
|
535
618
|
|
|
536
619
|
final_slug = sanitize_slug(slug or session.get("slug") or session.get("name") or "sub-app")
|
|
537
620
|
target_dir = os.path.join(current_app.config["INSTALLED_APPS_DIR"], final_slug)
|
|
621
|
+
logger.info("Finalizing installation of staged app '%s' into '%s'", final_slug, target_dir)
|
|
538
622
|
|
|
539
623
|
if os.path.exists(target_dir):
|
|
540
624
|
if os.path.exists(staging_dir):
|
|
@@ -585,6 +669,7 @@ def finalize_staged_installation(
|
|
|
585
669
|
else:
|
|
586
670
|
initial_settings[s_key] = s_val
|
|
587
671
|
|
|
672
|
+
logger.info("Installing dependencies for '%s'...", final_slug)
|
|
588
673
|
install_dependencies(target_dir)
|
|
589
674
|
|
|
590
675
|
installed_app = InstalledApp(
|
|
@@ -707,13 +792,267 @@ def export_app_to_zip(slug: str, output_path: Optional[str] = None) -> str:
|
|
|
707
792
|
return output_path
|
|
708
793
|
|
|
709
794
|
|
|
795
|
+
def update_app_from_git(app_id_or_slug: Any) -> Tuple[bool, str, Dict[str, Any]]:
|
|
796
|
+
"""
|
|
797
|
+
Pulls latest commits from remote Git repository for an installed app, executes
|
|
798
|
+
AST security pre-check audit on updated files, updates dependencies, and reloads WSGI cache.
|
|
799
|
+
"""
|
|
800
|
+
if isinstance(app_id_or_slug, int) or (
|
|
801
|
+
isinstance(app_id_or_slug, str) and app_id_or_slug.isdigit()
|
|
802
|
+
):
|
|
803
|
+
app_record = db.session.get(InstalledApp, int(app_id_or_slug))
|
|
804
|
+
else:
|
|
805
|
+
app_record = InstalledApp.query.filter_by(slug=str(app_id_or_slug)).first()
|
|
806
|
+
|
|
807
|
+
if not app_record:
|
|
808
|
+
return False, "Application not found in database.", {}
|
|
809
|
+
|
|
810
|
+
slug = app_record.slug
|
|
811
|
+
app_dir = os.path.join(current_app.config["INSTALLED_APPS_DIR"], slug)
|
|
812
|
+
|
|
813
|
+
if not os.path.exists(app_dir):
|
|
814
|
+
return False, f"App directory for '{app_record.name}' does not exist on disk.", {}
|
|
815
|
+
|
|
816
|
+
git_dir = os.path.join(app_dir, ".git")
|
|
817
|
+
if not os.path.exists(git_dir):
|
|
818
|
+
return (
|
|
819
|
+
False,
|
|
820
|
+
f"'{app_record.name}' was not installed via Git (no .git directory found).",
|
|
821
|
+
{},
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
# 1. Fetch and pull changes
|
|
825
|
+
try:
|
|
826
|
+
# Fetch remote refs
|
|
827
|
+
fetch_res = subprocess.run(
|
|
828
|
+
["git", "fetch", "origin"],
|
|
829
|
+
cwd=app_dir,
|
|
830
|
+
capture_output=True,
|
|
831
|
+
text=True,
|
|
832
|
+
timeout=60,
|
|
833
|
+
)
|
|
834
|
+
if fetch_res.returncode != 0:
|
|
835
|
+
return False, f"Git fetch failed: {fetch_res.stderr.strip()}", {}
|
|
836
|
+
|
|
837
|
+
# Reset / pull to origin HEAD
|
|
838
|
+
pull_res = subprocess.run(
|
|
839
|
+
["git", "pull", "--ff-only"],
|
|
840
|
+
cwd=app_dir,
|
|
841
|
+
capture_output=True,
|
|
842
|
+
text=True,
|
|
843
|
+
timeout=60,
|
|
844
|
+
)
|
|
845
|
+
if pull_res.returncode != 0:
|
|
846
|
+
# Fallback to merge or pull origin HEAD
|
|
847
|
+
pull_res = subprocess.run(
|
|
848
|
+
["git", "pull", "origin", "HEAD"],
|
|
849
|
+
cwd=app_dir,
|
|
850
|
+
capture_output=True,
|
|
851
|
+
text=True,
|
|
852
|
+
timeout=60,
|
|
853
|
+
)
|
|
854
|
+
if pull_res.returncode != 0:
|
|
855
|
+
return False, f"Git pull failed: {pull_res.stderr.strip()}", {}
|
|
856
|
+
|
|
857
|
+
except Exception as git_err:
|
|
858
|
+
return False, f"Git operation error: {str(git_err)}", {}
|
|
859
|
+
|
|
860
|
+
# 2. Run AST Security Scan on updated repository
|
|
861
|
+
scan_report = run_security_scan(app_dir)
|
|
862
|
+
if not scan_report.is_safe:
|
|
863
|
+
return (
|
|
864
|
+
False,
|
|
865
|
+
f"Security scan failed on updated code: {scan_report.summary}",
|
|
866
|
+
{"scan_report": scan_report.to_dict()},
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
# 3. Analyze and install dependencies
|
|
870
|
+
venv_mode = current_app.config.get("APP_VENV_MODE", "singular") if current_app else "singular"
|
|
871
|
+
manifest = parse_manifest(app_dir) or {}
|
|
872
|
+
dep_report = analyze_dependencies(app_dir, manifest=manifest, venv_mode=venv_mode)
|
|
873
|
+
|
|
874
|
+
dep_ok, dep_msg = install_dependencies(app_dir)
|
|
875
|
+
|
|
876
|
+
# 4. Update InstalledApp metadata
|
|
877
|
+
if manifest:
|
|
878
|
+
if manifest.get("name"):
|
|
879
|
+
app_record.name = manifest["name"]
|
|
880
|
+
if manifest.get("description"):
|
|
881
|
+
app_record.description = manifest["description"]
|
|
882
|
+
if manifest.get("entry_point"):
|
|
883
|
+
app_record.entry_point = manifest["entry_point"]
|
|
884
|
+
|
|
885
|
+
db.session.commit()
|
|
886
|
+
|
|
887
|
+
# 5. Clear WSGI cache and trigger signals
|
|
888
|
+
if hasattr(current_app, "extensions") and "appmanager" in current_app.extensions:
|
|
889
|
+
current_app.extensions["appmanager"].clear_cache(slug=slug)
|
|
890
|
+
|
|
891
|
+
try:
|
|
892
|
+
subapp_reloaded.send(None, slug=slug)
|
|
893
|
+
subapp_updated.send(None, app_slug=slug, app_id=app_record.id, update_type="git")
|
|
894
|
+
except Exception:
|
|
895
|
+
pass
|
|
896
|
+
|
|
897
|
+
try:
|
|
898
|
+
from appmanager.hooks import trigger_hook
|
|
899
|
+
|
|
900
|
+
trigger_hook("on_app_updated", app_id=app_record.id, app_slug=slug, update_type="git")
|
|
901
|
+
except Exception:
|
|
902
|
+
pass
|
|
903
|
+
|
|
904
|
+
return (
|
|
905
|
+
True,
|
|
906
|
+
f"Application '{app_record.name}' updated successfully from Git.",
|
|
907
|
+
{
|
|
908
|
+
"scan_report": scan_report.to_dict(),
|
|
909
|
+
"dependency_report": dep_report.to_dict(),
|
|
910
|
+
"dep_output": dep_msg,
|
|
911
|
+
"manifest": manifest,
|
|
912
|
+
},
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def stage_zip_replacement(
|
|
917
|
+
app_id_or_slug: Any,
|
|
918
|
+
zip_file_storage: Any,
|
|
919
|
+
) -> Tuple[str, SecurityScanReport, Dict[str, Any], DependencyAnalysisReport]:
|
|
920
|
+
"""
|
|
921
|
+
Stages an uploaded ZIP file for replacing an existing installed application.
|
|
922
|
+
"""
|
|
923
|
+
if isinstance(app_id_or_slug, int) or (
|
|
924
|
+
isinstance(app_id_or_slug, str) and app_id_or_slug.isdigit()
|
|
925
|
+
):
|
|
926
|
+
app_record = db.session.get(InstalledApp, int(app_id_or_slug))
|
|
927
|
+
else:
|
|
928
|
+
app_record = InstalledApp.query.filter_by(slug=str(app_id_or_slug)).first()
|
|
929
|
+
|
|
930
|
+
if not app_record:
|
|
931
|
+
raise ValueError("Target application to replace was not found.")
|
|
932
|
+
|
|
933
|
+
staging_id, scan_report, manifest = stage_zip_file(
|
|
934
|
+
zip_file_storage=zip_file_storage,
|
|
935
|
+
name=app_record.name,
|
|
936
|
+
slug=app_record.slug,
|
|
937
|
+
entry_point=app_record.entry_point,
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
session = _staged_installations[staging_id]
|
|
941
|
+
session["action"] = "replace"
|
|
942
|
+
session["target_app_id"] = app_record.id
|
|
943
|
+
_write_staging_metadata(session["staging_dir"], session)
|
|
944
|
+
|
|
945
|
+
dep_report = session["dependency_report"]
|
|
946
|
+
return staging_id, scan_report, manifest, dep_report
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def finalize_zip_replacement(staging_id: str) -> InstalledApp:
|
|
950
|
+
"""
|
|
951
|
+
Finalizes replacing an existing app's files with newly staged ZIP archive contents.
|
|
952
|
+
"""
|
|
953
|
+
session = get_staged_session(staging_id)
|
|
954
|
+
if not session:
|
|
955
|
+
raise ValueError(f"Staged session '{staging_id}' not found or expired.")
|
|
956
|
+
|
|
957
|
+
_staged_installations.pop(staging_id, None)
|
|
958
|
+
|
|
959
|
+
staging_dir = session["staging_dir"]
|
|
960
|
+
target_app_id = session.get("target_app_id")
|
|
961
|
+
target_slug = session.get("slug")
|
|
962
|
+
|
|
963
|
+
if target_app_id:
|
|
964
|
+
app_record = db.session.get(InstalledApp, target_app_id)
|
|
965
|
+
else:
|
|
966
|
+
app_record = InstalledApp.query.filter_by(slug=target_slug).first()
|
|
967
|
+
|
|
968
|
+
if not app_record:
|
|
969
|
+
if os.path.exists(staging_dir):
|
|
970
|
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
971
|
+
raise ValueError("Target app to replace was not found.")
|
|
972
|
+
|
|
973
|
+
target_dir = os.path.join(current_app.config["INSTALLED_APPS_DIR"], app_record.slug)
|
|
974
|
+
|
|
975
|
+
# Atomically replace target directory
|
|
976
|
+
temp_backup_dir = None
|
|
977
|
+
try:
|
|
978
|
+
import tempfile
|
|
979
|
+
|
|
980
|
+
if os.path.exists(target_dir):
|
|
981
|
+
temp_backup_dir = tempfile.mkdtemp(prefix="app_backup_")
|
|
982
|
+
# Move existing app out
|
|
983
|
+
for item in os.listdir(target_dir):
|
|
984
|
+
s = os.path.join(target_dir, item)
|
|
985
|
+
d = os.path.join(temp_backup_dir, item)
|
|
986
|
+
shutil.move(s, d)
|
|
987
|
+
|
|
988
|
+
# Move new staged files in
|
|
989
|
+
meta_path = os.path.join(staging_dir, "_staging_meta.json")
|
|
990
|
+
if os.path.exists(meta_path):
|
|
991
|
+
try:
|
|
992
|
+
os.remove(meta_path)
|
|
993
|
+
except Exception:
|
|
994
|
+
pass
|
|
995
|
+
|
|
996
|
+
for item in os.listdir(staging_dir):
|
|
997
|
+
s = os.path.join(staging_dir, item)
|
|
998
|
+
d = os.path.join(target_dir, item)
|
|
999
|
+
shutil.move(s, d)
|
|
1000
|
+
|
|
1001
|
+
if os.path.exists(staging_dir):
|
|
1002
|
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
1003
|
+
|
|
1004
|
+
if temp_backup_dir and os.path.exists(temp_backup_dir):
|
|
1005
|
+
shutil.rmtree(temp_backup_dir, ignore_errors=True)
|
|
1006
|
+
|
|
1007
|
+
except Exception as e:
|
|
1008
|
+
if temp_backup_dir and os.path.exists(temp_backup_dir) and os.path.exists(target_dir):
|
|
1009
|
+
# Restore backup on failure
|
|
1010
|
+
shutil.rmtree(target_dir, ignore_errors=True)
|
|
1011
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
1012
|
+
for item in os.listdir(temp_backup_dir):
|
|
1013
|
+
shutil.move(os.path.join(temp_backup_dir, item), os.path.join(target_dir, item))
|
|
1014
|
+
shutil.rmtree(temp_backup_dir, ignore_errors=True)
|
|
1015
|
+
raise RuntimeError(f"Failed to replace application files: {e}")
|
|
1016
|
+
|
|
1017
|
+
# Update dependencies and manifest
|
|
1018
|
+
install_dependencies(target_dir)
|
|
1019
|
+
|
|
1020
|
+
manifest = parse_manifest(target_dir) or {}
|
|
1021
|
+
if manifest:
|
|
1022
|
+
if manifest.get("name"):
|
|
1023
|
+
app_record.name = manifest["name"]
|
|
1024
|
+
if manifest.get("description"):
|
|
1025
|
+
app_record.description = manifest["description"]
|
|
1026
|
+
if manifest.get("entry_point"):
|
|
1027
|
+
app_record.entry_point = manifest["entry_point"]
|
|
1028
|
+
|
|
1029
|
+
db.session.commit()
|
|
1030
|
+
|
|
1031
|
+
# Clear cache and trigger reload
|
|
1032
|
+
if hasattr(current_app, "extensions") and "appmanager" in current_app.extensions:
|
|
1033
|
+
current_app.extensions["appmanager"].clear_cache(slug=app_record.slug)
|
|
1034
|
+
|
|
1035
|
+
try:
|
|
1036
|
+
subapp_reloaded.send(None, slug=app_record.slug)
|
|
1037
|
+
subapp_updated.send(None, app_slug=app_record.slug, app_id=app_record.id, update_type="zip")
|
|
1038
|
+
except Exception:
|
|
1039
|
+
pass
|
|
1040
|
+
|
|
1041
|
+
return app_record
|
|
1042
|
+
|
|
1043
|
+
|
|
710
1044
|
def uninstall_app(app_id: int) -> Tuple[bool, str]:
|
|
711
1045
|
app_record = db.session.get(InstalledApp, app_id)
|
|
712
1046
|
if not app_record:
|
|
713
1047
|
return False, "App not found."
|
|
714
1048
|
|
|
715
1049
|
slug = app_record.slug
|
|
716
|
-
|
|
1050
|
+
installed_apps_dir = current_app.config["INSTALLED_APPS_DIR"]
|
|
1051
|
+
target_dir = os.path.join(installed_apps_dir, slug)
|
|
1052
|
+
|
|
1053
|
+
# 1. Analyze dependencies to preserve shared packages
|
|
1054
|
+
dep_summary = analyze_uninstall_dependencies(slug, installed_apps_dir)
|
|
1055
|
+
|
|
717
1056
|
if os.path.exists(target_dir):
|
|
718
1057
|
shutil.rmtree(target_dir, ignore_errors=True)
|
|
719
1058
|
|
|
@@ -736,4 +1075,7 @@ def uninstall_app(app_id: int) -> Tuple[bool, str]:
|
|
|
736
1075
|
except Exception:
|
|
737
1076
|
pass
|
|
738
1077
|
|
|
739
|
-
|
|
1078
|
+
preserved_msg = (
|
|
1079
|
+
f" ({dep_summary['message']})" if dep_summary.get("shared_packages_preserved") else ""
|
|
1080
|
+
)
|
|
1081
|
+
return True, f"Application '{app_record.name}' uninstalled successfully.{preserved_msg}"
|