appmanager-server 0.2.0__tar.gz → 0.3.0__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.2.0 → appmanager_server-0.3.0}/.env.example +10 -1
- appmanager_server-0.3.0/CHANGELOG.md +52 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/MANIFEST.in +1 -0
- {appmanager_server-0.2.0/appmanager_server.egg-info → appmanager_server-0.3.0}/PKG-INFO +7 -2
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/README.md +5 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/__init__.py +1 -1
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/admin/app_installer.py +323 -20
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/admin/routes.py +131 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/auth/routes.py +45 -12
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/cli.py +169 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/config.py +23 -0
- appmanager_server-0.3.0/appmanager/dependency_manager.py +396 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/signals.py +1 -0
- appmanager_server-0.3.0/appmanager/templates/admin/app_detail.html +276 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/admin/dashboard.html +187 -56
- {appmanager_server-0.2.0 → appmanager_server-0.3.0/appmanager_server.egg-info}/PKG-INFO +7 -2
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager_server.egg-info/SOURCES.txt +5 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/pyproject.toml +2 -2
- appmanager_server-0.3.0/tests/test_app_updates.py +136 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_auth.py +47 -2
- appmanager_server-0.3.0/tests/test_dependency_manager.py +118 -0
- appmanager_server-0.3.0/tests/test_nested_portal.py +123 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_packaging_and_cli.py +37 -2
- appmanager_server-0.2.0/appmanager/templates/admin/app_detail.html +0 -136
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/LICENSE +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/SECURITY.md +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/admin/__init__.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/admin/members.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/admin/registry.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/api/__init__.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/api/routes.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/app_config.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/auth/__init__.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/auth/oauth.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/auth/utils.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/bridge.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/database.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/extension.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/extensions.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/health.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/hooks.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/middleware.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/models.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/py.typed +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/sdk.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/security.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/security_scanner.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/css/app.css +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/fonts.css +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/htmx/htmx.min.js +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/admin/_member_drawer.html +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/admin/_members_table.html +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/admin/flairs.html +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/admin/permissions.html +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/admin/roles.html +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/admin/users.html +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/auth/login.html +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/auth/profile.html +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/base.html +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/errors/401.html +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/errors/403.html +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/errors/404.html +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/errors/500.html +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/index.html +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager_server.egg-info/dependency_links.txt +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager_server.egg-info/entry_points.txt +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager_server.egg-info/requires.txt +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager_server.egg-info/top_level.txt +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/requirements.txt +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/setup.cfg +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_api.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_app_settings.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_app_settings_config.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_cli_dev_and_templates.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_cli_health.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_cli_scaffolding.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_error_pages.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_extension_apps.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_extension_pattern.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_hooks.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_installer.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_invalid_install_failures.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_manifest_sdk.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_middleware.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_role_management.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_sdk.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_security_hardening.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_security_precheck_flow.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_security_scanner.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_signals.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_user_elevation_and_visibility.py +0 -0
- {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_user_tracking.py +0 -0
|
@@ -34,9 +34,18 @@ GOOGLE_CLIENT_ID=
|
|
|
34
34
|
GOOGLE_CLIENT_SECRET=
|
|
35
35
|
GOOGLE_DISCOVERY_URL=https://accounts.google.com/.well-known/openid-configuration
|
|
36
36
|
|
|
37
|
-
# SMTP Email Configuration (
|
|
37
|
+
# SMTP Email Configuration (Required for Magic Link email delivery in production)
|
|
38
38
|
SMTP_SERVER=
|
|
39
39
|
SMTP_PORT=587
|
|
40
40
|
SMTP_USER=
|
|
41
41
|
SMTP_PASSWORD=
|
|
42
42
|
MAIL_DEFAULT_SENDER=noreply@appmanager.local
|
|
43
|
+
|
|
44
|
+
# Developer Magic Login (Set to true to show one-click login button when SMTP is disabled)
|
|
45
|
+
ALLOW_DEV_MAGIC_LOGIN=false
|
|
46
|
+
|
|
47
|
+
# Role & Admin Provisioning
|
|
48
|
+
# FIRST_USER_IS_ADMIN: If true, the very first user who registers will be granted the admin role
|
|
49
|
+
FIRST_USER_IS_ADMIN=true
|
|
50
|
+
# ADMIN_EMAILS: Comma-separated list of emails that automatically receive admin role upon registration
|
|
51
|
+
ADMIN_EMAILS=
|
|
@@ -0,0 +1,52 @@
|
|
|
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.0] - 2026-08-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Dependency & Environment Conflict Engine (`appmanager.dependency_manager`)**:
|
|
14
|
+
- Python runtime version verification comparing manifest `requires_python` against `platform.python_version()`.
|
|
15
|
+
- PEP 508 requirement analyzer checking active environment distributions for satisfied packages, packages to install, and conflicts against core host frameworks (`Flask`, `SQLAlchemy`, etc.).
|
|
16
|
+
- Shared dependency preservation analyzer protecting packages needed by other sub-apps when uninstalling.
|
|
17
|
+
- **Configurable Virtual Environment Modes (`APP_VENV_MODE`)**:
|
|
18
|
+
- `"singular"` (Default): Single shared host environment.
|
|
19
|
+
- `"isolated"`: Per-app isolated virtual environment (`.venv/`).
|
|
20
|
+
- **One-Click Sub-App Updates & Replacements**:
|
|
21
|
+
- Git Update action (`POST /admin/apps/<id>/update-git`) pulling remote changes with automated AST security scan, dependency sync, and cache reload.
|
|
22
|
+
- In-place ZIP replacement (`POST /admin/apps/<id>/precheck-replace-zip` -> `confirm-replace-zip`) with atomic rollback protection.
|
|
23
|
+
- **CLI Commands**:
|
|
24
|
+
- `appmanager update <slug> [--zip <path>]`
|
|
25
|
+
- `appmanager check-deps [slug] [--all] [--install]`
|
|
26
|
+
- `appmanager install-deps [slug] [--all]`
|
|
27
|
+
- **Nested AppManager Portals**: Support for nesting AppManager instances as sub-apps within parent AppManager instances with recursive dispatching.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## [0.2.1] - 2026-08-28
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
- **Configurable Dev Magic Login (`ALLOW_DEV_MAGIC_LOGIN`)**: Prevents showing one-click developer login buttons in production when SMTP is unconfigured unless explicitly permitted.
|
|
35
|
+
- **Configurable Admin Role Provisioning (`ADMIN_EMAILS` & `FIRST_USER_IS_ADMIN`)**:
|
|
36
|
+
- `ADMIN_EMAILS`: Specify comma-separated list of admin email addresses.
|
|
37
|
+
- `FIRST_USER_IS_ADMIN`: Controls whether the very first registered user in a blank database receives the admin role (defaults to `true`).
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
- Improved error messaging on `/auth/login` when SMTP is unconfigured.
|
|
41
|
+
- Expanded authentication test suite.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## [0.2.0] - 2026-08-28
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
- Initial PyPI release under package name **`appmanager-server`**.
|
|
49
|
+
- CLI commands and aliases: `appmanager-server`, `appmgr-server`, `appmanager`, and `appmgr`.
|
|
50
|
+
- AST Security Scanner (`appmanager.security_scanner.SecurityScanner`) for ZIP and Git package pre-installation checks.
|
|
51
|
+
- Python 3.10-3.13 support.
|
|
52
|
+
- PyPI Trusted Publishing via GitHub Actions OIDC.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: appmanager-server
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.3.0
|
|
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,5 +1,6 @@
|
|
|
1
1
|
import ast
|
|
2
2
|
import importlib.util
|
|
3
|
+
import inspect
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
5
6
|
import shutil
|
|
@@ -14,6 +15,12 @@ from flask import current_app
|
|
|
14
15
|
from werkzeug.utils import secure_filename
|
|
15
16
|
|
|
16
17
|
from appmanager.database import db
|
|
18
|
+
from appmanager.dependency_manager import (
|
|
19
|
+
DependencyAnalysisReport,
|
|
20
|
+
analyze_dependencies,
|
|
21
|
+
analyze_uninstall_dependencies,
|
|
22
|
+
install_app_dependencies,
|
|
23
|
+
)
|
|
17
24
|
from appmanager.models import InstalledApp, User, UserAppPermission
|
|
18
25
|
from appmanager.security import (
|
|
19
26
|
extract_zip_safely,
|
|
@@ -21,7 +28,7 @@ from appmanager.security import (
|
|
|
21
28
|
validate_entrypoint_path,
|
|
22
29
|
)
|
|
23
30
|
from appmanager.security_scanner import SecurityScanReport, run_security_scan
|
|
24
|
-
from appmanager.signals import subapp_installed, subapp_uninstalled
|
|
31
|
+
from appmanager.signals import subapp_installed, subapp_reloaded, subapp_uninstalled, subapp_updated
|
|
25
32
|
|
|
26
33
|
|
|
27
34
|
def sanitize_slug(slug_text: str) -> str:
|
|
@@ -227,15 +234,18 @@ def discover_entrypoint(app_dir: str) -> str:
|
|
|
227
234
|
return "app:app"
|
|
228
235
|
|
|
229
236
|
|
|
230
|
-
def install_dependencies(app_dir: str) ->
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
237
|
+
def install_dependencies(app_dir: str) -> Tuple[bool, str]:
|
|
238
|
+
"""
|
|
239
|
+
Installs sub-app dependencies using the configured virtual environment mode (singular or isolated).
|
|
240
|
+
"""
|
|
241
|
+
venv_mode = "singular"
|
|
242
|
+
if current_app:
|
|
243
|
+
venv_mode = current_app.config.get("APP_VENV_MODE", "singular")
|
|
244
|
+
|
|
245
|
+
ok, msg = install_app_dependencies(app_dir, venv_mode=venv_mode)
|
|
246
|
+
if not ok:
|
|
247
|
+
print(f"[INSTALLER WARNING] {msg}")
|
|
248
|
+
return ok, msg
|
|
239
249
|
|
|
240
250
|
|
|
241
251
|
def load_wsgi_app_from_path(app_dir: str, entry_point: str = "app:app") -> Any:
|
|
@@ -267,15 +277,37 @@ def load_wsgi_app_from_path(app_dir: str, entry_point: str = "app:app") -> Any:
|
|
|
267
277
|
sys.modules[scoped_module_name] = mod
|
|
268
278
|
spec.loader.exec_module(mod)
|
|
269
279
|
|
|
270
|
-
# Check for Flask app instance
|
|
280
|
+
# Check for Flask app instance, callable create_app factory, or WSGI middleware
|
|
271
281
|
if hasattr(mod, app_var_name):
|
|
272
282
|
app_obj = getattr(mod, app_var_name)
|
|
273
|
-
if
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
283
|
+
if hasattr(app_obj, "wsgi_app"):
|
|
284
|
+
return app_obj
|
|
285
|
+
|
|
286
|
+
if callable(app_obj):
|
|
287
|
+
if inspect.isfunction(app_obj) or inspect.ismethod(app_obj):
|
|
288
|
+
try:
|
|
289
|
+
sig = inspect.signature(app_obj)
|
|
290
|
+
params = list(sig.parameters.values())
|
|
291
|
+
# Standard WSGI callable: (environ, start_response)
|
|
292
|
+
if len(params) == 2 and params[0].name in ("environ", "env"):
|
|
293
|
+
return app_obj
|
|
294
|
+
required_params = [
|
|
295
|
+
p
|
|
296
|
+
for p in params
|
|
297
|
+
if p.default == inspect.Parameter.empty
|
|
298
|
+
and p.kind
|
|
299
|
+
in (
|
|
300
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
301
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
302
|
+
)
|
|
303
|
+
]
|
|
304
|
+
if len(required_params) == 0:
|
|
305
|
+
return app_obj()
|
|
306
|
+
except Exception:
|
|
307
|
+
pass
|
|
308
|
+
return app_obj
|
|
309
|
+
|
|
310
|
+
return app_obj
|
|
279
311
|
|
|
280
312
|
raise AttributeError(f"Module '{module_name}' in {app_dir} does not export '{app_var_name}'")
|
|
281
313
|
|
|
@@ -289,10 +321,14 @@ def _write_staging_metadata(staging_dir: str, meta: Dict[str, Any]) -> None:
|
|
|
289
321
|
"""Saves staging session metadata to disk inside the sandboxed staging directory."""
|
|
290
322
|
meta_path = os.path.join(staging_dir, "_staging_meta.json")
|
|
291
323
|
try:
|
|
292
|
-
# Don't serialize scan_report object directly to JSON if it's an object
|
|
324
|
+
# Don't serialize scan_report / dependency_report object directly to JSON if it's an object
|
|
293
325
|
serializable = dict(meta)
|
|
294
326
|
if "scan_report" in serializable and hasattr(serializable["scan_report"], "to_dict"):
|
|
295
327
|
serializable["scan_report"] = serializable["scan_report"].to_dict()
|
|
328
|
+
if "dependency_report" in serializable and hasattr(
|
|
329
|
+
serializable["dependency_report"], "to_dict"
|
|
330
|
+
):
|
|
331
|
+
serializable["dependency_report"] = serializable["dependency_report"].to_dict()
|
|
296
332
|
with open(meta_path, "w", encoding="utf-8") as f:
|
|
297
333
|
json.dump(serializable, f)
|
|
298
334
|
except Exception as e:
|
|
@@ -417,6 +453,10 @@ def stage_git_repo(
|
|
|
417
453
|
# Run static security pre-check
|
|
418
454
|
scan_report = run_security_scan(temp_stage_dir)
|
|
419
455
|
|
|
456
|
+
# Run dependency analysis
|
|
457
|
+
venv_mode = current_app.config.get("APP_VENV_MODE", "singular") if current_app else "singular"
|
|
458
|
+
dep_report = analyze_dependencies(temp_stage_dir, manifest=manifest, venv_mode=venv_mode)
|
|
459
|
+
|
|
420
460
|
_staged_installations[staging_id] = {
|
|
421
461
|
"staging_id": staging_id,
|
|
422
462
|
"staging_dir": temp_stage_dir,
|
|
@@ -427,6 +467,7 @@ def stage_git_repo(
|
|
|
427
467
|
"entry_point": resolved_entry_point,
|
|
428
468
|
"manifest": manifest,
|
|
429
469
|
"scan_report": scan_report,
|
|
470
|
+
"dependency_report": dep_report,
|
|
430
471
|
"created_at": time.time(),
|
|
431
472
|
}
|
|
432
473
|
_write_staging_metadata(temp_stage_dir, _staged_installations[staging_id])
|
|
@@ -494,6 +535,10 @@ def stage_zip_file(
|
|
|
494
535
|
# Run static security pre-check
|
|
495
536
|
scan_report = run_security_scan(temp_stage_dir)
|
|
496
537
|
|
|
538
|
+
# Run dependency analysis
|
|
539
|
+
venv_mode = current_app.config.get("APP_VENV_MODE", "singular") if current_app else "singular"
|
|
540
|
+
dep_report = analyze_dependencies(temp_stage_dir, manifest=manifest, venv_mode=venv_mode)
|
|
541
|
+
|
|
497
542
|
_staged_installations[staging_id] = {
|
|
498
543
|
"staging_id": staging_id,
|
|
499
544
|
"staging_dir": temp_stage_dir,
|
|
@@ -504,6 +549,7 @@ def stage_zip_file(
|
|
|
504
549
|
"entry_point": resolved_entry_point,
|
|
505
550
|
"manifest": manifest,
|
|
506
551
|
"scan_report": scan_report,
|
|
552
|
+
"dependency_report": dep_report,
|
|
507
553
|
"created_at": time.time(),
|
|
508
554
|
}
|
|
509
555
|
_write_staging_metadata(temp_stage_dir, _staged_installations[staging_id])
|
|
@@ -707,13 +753,267 @@ def export_app_to_zip(slug: str, output_path: Optional[str] = None) -> str:
|
|
|
707
753
|
return output_path
|
|
708
754
|
|
|
709
755
|
|
|
756
|
+
def update_app_from_git(app_id_or_slug: Any) -> Tuple[bool, str, Dict[str, Any]]:
|
|
757
|
+
"""
|
|
758
|
+
Pulls latest commits from remote Git repository for an installed app, executes
|
|
759
|
+
AST security pre-check audit on updated files, updates dependencies, and reloads WSGI cache.
|
|
760
|
+
"""
|
|
761
|
+
if isinstance(app_id_or_slug, int) or (
|
|
762
|
+
isinstance(app_id_or_slug, str) and app_id_or_slug.isdigit()
|
|
763
|
+
):
|
|
764
|
+
app_record = db.session.get(InstalledApp, int(app_id_or_slug))
|
|
765
|
+
else:
|
|
766
|
+
app_record = InstalledApp.query.filter_by(slug=str(app_id_or_slug)).first()
|
|
767
|
+
|
|
768
|
+
if not app_record:
|
|
769
|
+
return False, "Application not found in database.", {}
|
|
770
|
+
|
|
771
|
+
slug = app_record.slug
|
|
772
|
+
app_dir = os.path.join(current_app.config["INSTALLED_APPS_DIR"], slug)
|
|
773
|
+
|
|
774
|
+
if not os.path.exists(app_dir):
|
|
775
|
+
return False, f"App directory for '{app_record.name}' does not exist on disk.", {}
|
|
776
|
+
|
|
777
|
+
git_dir = os.path.join(app_dir, ".git")
|
|
778
|
+
if not os.path.exists(git_dir):
|
|
779
|
+
return (
|
|
780
|
+
False,
|
|
781
|
+
f"'{app_record.name}' was not installed via Git (no .git directory found).",
|
|
782
|
+
{},
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
# 1. Fetch and pull changes
|
|
786
|
+
try:
|
|
787
|
+
# Fetch remote refs
|
|
788
|
+
fetch_res = subprocess.run(
|
|
789
|
+
["git", "fetch", "origin"],
|
|
790
|
+
cwd=app_dir,
|
|
791
|
+
capture_output=True,
|
|
792
|
+
text=True,
|
|
793
|
+
timeout=60,
|
|
794
|
+
)
|
|
795
|
+
if fetch_res.returncode != 0:
|
|
796
|
+
return False, f"Git fetch failed: {fetch_res.stderr.strip()}", {}
|
|
797
|
+
|
|
798
|
+
# Reset / pull to origin HEAD
|
|
799
|
+
pull_res = subprocess.run(
|
|
800
|
+
["git", "pull", "--ff-only"],
|
|
801
|
+
cwd=app_dir,
|
|
802
|
+
capture_output=True,
|
|
803
|
+
text=True,
|
|
804
|
+
timeout=60,
|
|
805
|
+
)
|
|
806
|
+
if pull_res.returncode != 0:
|
|
807
|
+
# Fallback to merge or pull origin HEAD
|
|
808
|
+
pull_res = subprocess.run(
|
|
809
|
+
["git", "pull", "origin", "HEAD"],
|
|
810
|
+
cwd=app_dir,
|
|
811
|
+
capture_output=True,
|
|
812
|
+
text=True,
|
|
813
|
+
timeout=60,
|
|
814
|
+
)
|
|
815
|
+
if pull_res.returncode != 0:
|
|
816
|
+
return False, f"Git pull failed: {pull_res.stderr.strip()}", {}
|
|
817
|
+
|
|
818
|
+
except Exception as git_err:
|
|
819
|
+
return False, f"Git operation error: {str(git_err)}", {}
|
|
820
|
+
|
|
821
|
+
# 2. Run AST Security Scan on updated repository
|
|
822
|
+
scan_report = run_security_scan(app_dir)
|
|
823
|
+
if not scan_report.is_safe:
|
|
824
|
+
return (
|
|
825
|
+
False,
|
|
826
|
+
f"Security scan failed on updated code: {scan_report.summary}",
|
|
827
|
+
{"scan_report": scan_report.to_dict()},
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
# 3. Analyze and install dependencies
|
|
831
|
+
venv_mode = current_app.config.get("APP_VENV_MODE", "singular") if current_app else "singular"
|
|
832
|
+
manifest = parse_manifest(app_dir) or {}
|
|
833
|
+
dep_report = analyze_dependencies(app_dir, manifest=manifest, venv_mode=venv_mode)
|
|
834
|
+
|
|
835
|
+
dep_ok, dep_msg = install_dependencies(app_dir)
|
|
836
|
+
|
|
837
|
+
# 4. Update InstalledApp metadata
|
|
838
|
+
if manifest:
|
|
839
|
+
if manifest.get("name"):
|
|
840
|
+
app_record.name = manifest["name"]
|
|
841
|
+
if manifest.get("description"):
|
|
842
|
+
app_record.description = manifest["description"]
|
|
843
|
+
if manifest.get("entry_point"):
|
|
844
|
+
app_record.entry_point = manifest["entry_point"]
|
|
845
|
+
|
|
846
|
+
db.session.commit()
|
|
847
|
+
|
|
848
|
+
# 5. Clear WSGI cache and trigger signals
|
|
849
|
+
if hasattr(current_app, "extensions") and "appmanager" in current_app.extensions:
|
|
850
|
+
current_app.extensions["appmanager"].clear_cache(slug=slug)
|
|
851
|
+
|
|
852
|
+
try:
|
|
853
|
+
subapp_reloaded.send(None, slug=slug)
|
|
854
|
+
subapp_updated.send(None, app_slug=slug, app_id=app_record.id, update_type="git")
|
|
855
|
+
except Exception:
|
|
856
|
+
pass
|
|
857
|
+
|
|
858
|
+
try:
|
|
859
|
+
from appmanager.hooks import trigger_hook
|
|
860
|
+
|
|
861
|
+
trigger_hook("on_app_updated", app_id=app_record.id, app_slug=slug, update_type="git")
|
|
862
|
+
except Exception:
|
|
863
|
+
pass
|
|
864
|
+
|
|
865
|
+
return (
|
|
866
|
+
True,
|
|
867
|
+
f"Application '{app_record.name}' updated successfully from Git.",
|
|
868
|
+
{
|
|
869
|
+
"scan_report": scan_report.to_dict(),
|
|
870
|
+
"dependency_report": dep_report.to_dict(),
|
|
871
|
+
"dep_output": dep_msg,
|
|
872
|
+
"manifest": manifest,
|
|
873
|
+
},
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def stage_zip_replacement(
|
|
878
|
+
app_id_or_slug: Any,
|
|
879
|
+
zip_file_storage: Any,
|
|
880
|
+
) -> Tuple[str, SecurityScanReport, Dict[str, Any], DependencyAnalysisReport]:
|
|
881
|
+
"""
|
|
882
|
+
Stages an uploaded ZIP file for replacing an existing installed application.
|
|
883
|
+
"""
|
|
884
|
+
if isinstance(app_id_or_slug, int) or (
|
|
885
|
+
isinstance(app_id_or_slug, str) and app_id_or_slug.isdigit()
|
|
886
|
+
):
|
|
887
|
+
app_record = db.session.get(InstalledApp, int(app_id_or_slug))
|
|
888
|
+
else:
|
|
889
|
+
app_record = InstalledApp.query.filter_by(slug=str(app_id_or_slug)).first()
|
|
890
|
+
|
|
891
|
+
if not app_record:
|
|
892
|
+
raise ValueError("Target application to replace was not found.")
|
|
893
|
+
|
|
894
|
+
staging_id, scan_report, manifest = stage_zip_file(
|
|
895
|
+
zip_file_storage=zip_file_storage,
|
|
896
|
+
name=app_record.name,
|
|
897
|
+
slug=app_record.slug,
|
|
898
|
+
entry_point=app_record.entry_point,
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
session = _staged_installations[staging_id]
|
|
902
|
+
session["action"] = "replace"
|
|
903
|
+
session["target_app_id"] = app_record.id
|
|
904
|
+
_write_staging_metadata(session["staging_dir"], session)
|
|
905
|
+
|
|
906
|
+
dep_report = session["dependency_report"]
|
|
907
|
+
return staging_id, scan_report, manifest, dep_report
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def finalize_zip_replacement(staging_id: str) -> InstalledApp:
|
|
911
|
+
"""
|
|
912
|
+
Finalizes replacing an existing app's files with newly staged ZIP archive contents.
|
|
913
|
+
"""
|
|
914
|
+
session = get_staged_session(staging_id)
|
|
915
|
+
if not session:
|
|
916
|
+
raise ValueError(f"Staged session '{staging_id}' not found or expired.")
|
|
917
|
+
|
|
918
|
+
_staged_installations.pop(staging_id, None)
|
|
919
|
+
|
|
920
|
+
staging_dir = session["staging_dir"]
|
|
921
|
+
target_app_id = session.get("target_app_id")
|
|
922
|
+
target_slug = session.get("slug")
|
|
923
|
+
|
|
924
|
+
if target_app_id:
|
|
925
|
+
app_record = db.session.get(InstalledApp, target_app_id)
|
|
926
|
+
else:
|
|
927
|
+
app_record = InstalledApp.query.filter_by(slug=target_slug).first()
|
|
928
|
+
|
|
929
|
+
if not app_record:
|
|
930
|
+
if os.path.exists(staging_dir):
|
|
931
|
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
932
|
+
raise ValueError("Target app to replace was not found.")
|
|
933
|
+
|
|
934
|
+
target_dir = os.path.join(current_app.config["INSTALLED_APPS_DIR"], app_record.slug)
|
|
935
|
+
|
|
936
|
+
# Atomically replace target directory
|
|
937
|
+
temp_backup_dir = None
|
|
938
|
+
try:
|
|
939
|
+
import tempfile
|
|
940
|
+
|
|
941
|
+
if os.path.exists(target_dir):
|
|
942
|
+
temp_backup_dir = tempfile.mkdtemp(prefix="app_backup_")
|
|
943
|
+
# Move existing app out
|
|
944
|
+
for item in os.listdir(target_dir):
|
|
945
|
+
s = os.path.join(target_dir, item)
|
|
946
|
+
d = os.path.join(temp_backup_dir, item)
|
|
947
|
+
shutil.move(s, d)
|
|
948
|
+
|
|
949
|
+
# Move new staged files in
|
|
950
|
+
meta_path = os.path.join(staging_dir, "_staging_meta.json")
|
|
951
|
+
if os.path.exists(meta_path):
|
|
952
|
+
try:
|
|
953
|
+
os.remove(meta_path)
|
|
954
|
+
except Exception:
|
|
955
|
+
pass
|
|
956
|
+
|
|
957
|
+
for item in os.listdir(staging_dir):
|
|
958
|
+
s = os.path.join(staging_dir, item)
|
|
959
|
+
d = os.path.join(target_dir, item)
|
|
960
|
+
shutil.move(s, d)
|
|
961
|
+
|
|
962
|
+
if os.path.exists(staging_dir):
|
|
963
|
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
964
|
+
|
|
965
|
+
if temp_backup_dir and os.path.exists(temp_backup_dir):
|
|
966
|
+
shutil.rmtree(temp_backup_dir, ignore_errors=True)
|
|
967
|
+
|
|
968
|
+
except Exception as e:
|
|
969
|
+
if temp_backup_dir and os.path.exists(temp_backup_dir) and os.path.exists(target_dir):
|
|
970
|
+
# Restore backup on failure
|
|
971
|
+
shutil.rmtree(target_dir, ignore_errors=True)
|
|
972
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
973
|
+
for item in os.listdir(temp_backup_dir):
|
|
974
|
+
shutil.move(os.path.join(temp_backup_dir, item), os.path.join(target_dir, item))
|
|
975
|
+
shutil.rmtree(temp_backup_dir, ignore_errors=True)
|
|
976
|
+
raise RuntimeError(f"Failed to replace application files: {e}")
|
|
977
|
+
|
|
978
|
+
# Update dependencies and manifest
|
|
979
|
+
install_dependencies(target_dir)
|
|
980
|
+
|
|
981
|
+
manifest = parse_manifest(target_dir) or {}
|
|
982
|
+
if manifest:
|
|
983
|
+
if manifest.get("name"):
|
|
984
|
+
app_record.name = manifest["name"]
|
|
985
|
+
if manifest.get("description"):
|
|
986
|
+
app_record.description = manifest["description"]
|
|
987
|
+
if manifest.get("entry_point"):
|
|
988
|
+
app_record.entry_point = manifest["entry_point"]
|
|
989
|
+
|
|
990
|
+
db.session.commit()
|
|
991
|
+
|
|
992
|
+
# Clear cache and trigger reload
|
|
993
|
+
if hasattr(current_app, "extensions") and "appmanager" in current_app.extensions:
|
|
994
|
+
current_app.extensions["appmanager"].clear_cache(slug=app_record.slug)
|
|
995
|
+
|
|
996
|
+
try:
|
|
997
|
+
subapp_reloaded.send(None, slug=app_record.slug)
|
|
998
|
+
subapp_updated.send(None, app_slug=app_record.slug, app_id=app_record.id, update_type="zip")
|
|
999
|
+
except Exception:
|
|
1000
|
+
pass
|
|
1001
|
+
|
|
1002
|
+
return app_record
|
|
1003
|
+
|
|
1004
|
+
|
|
710
1005
|
def uninstall_app(app_id: int) -> Tuple[bool, str]:
|
|
711
1006
|
app_record = db.session.get(InstalledApp, app_id)
|
|
712
1007
|
if not app_record:
|
|
713
1008
|
return False, "App not found."
|
|
714
1009
|
|
|
715
1010
|
slug = app_record.slug
|
|
716
|
-
|
|
1011
|
+
installed_apps_dir = current_app.config["INSTALLED_APPS_DIR"]
|
|
1012
|
+
target_dir = os.path.join(installed_apps_dir, slug)
|
|
1013
|
+
|
|
1014
|
+
# 1. Analyze dependencies to preserve shared packages
|
|
1015
|
+
dep_summary = analyze_uninstall_dependencies(slug, installed_apps_dir)
|
|
1016
|
+
|
|
717
1017
|
if os.path.exists(target_dir):
|
|
718
1018
|
shutil.rmtree(target_dir, ignore_errors=True)
|
|
719
1019
|
|
|
@@ -736,4 +1036,7 @@ def uninstall_app(app_id: int) -> Tuple[bool, str]:
|
|
|
736
1036
|
except Exception:
|
|
737
1037
|
pass
|
|
738
1038
|
|
|
739
|
-
|
|
1039
|
+
preserved_msg = (
|
|
1040
|
+
f" ({dep_summary['message']})" if dep_summary.get("shared_packages_preserved") else ""
|
|
1041
|
+
)
|
|
1042
|
+
return True, f"Application '{app_record.name}' uninstalled successfully.{preserved_msg}"
|