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.
Files changed (128) hide show
  1. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/.env.example +10 -1
  2. appmanager_server-0.3.0/CHANGELOG.md +52 -0
  3. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/MANIFEST.in +1 -0
  4. {appmanager_server-0.2.0/appmanager_server.egg-info → appmanager_server-0.3.0}/PKG-INFO +7 -2
  5. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/README.md +5 -0
  6. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/__init__.py +1 -1
  7. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/admin/app_installer.py +323 -20
  8. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/admin/routes.py +131 -0
  9. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/auth/routes.py +45 -12
  10. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/cli.py +169 -0
  11. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/config.py +23 -0
  12. appmanager_server-0.3.0/appmanager/dependency_manager.py +396 -0
  13. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/signals.py +1 -0
  14. appmanager_server-0.3.0/appmanager/templates/admin/app_detail.html +276 -0
  15. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/admin/dashboard.html +187 -56
  16. {appmanager_server-0.2.0 → appmanager_server-0.3.0/appmanager_server.egg-info}/PKG-INFO +7 -2
  17. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager_server.egg-info/SOURCES.txt +5 -0
  18. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/pyproject.toml +2 -2
  19. appmanager_server-0.3.0/tests/test_app_updates.py +136 -0
  20. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_auth.py +47 -2
  21. appmanager_server-0.3.0/tests/test_dependency_manager.py +118 -0
  22. appmanager_server-0.3.0/tests/test_nested_portal.py +123 -0
  23. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_packaging_and_cli.py +37 -2
  24. appmanager_server-0.2.0/appmanager/templates/admin/app_detail.html +0 -136
  25. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/LICENSE +0 -0
  26. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/SECURITY.md +0 -0
  27. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/admin/__init__.py +0 -0
  28. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/admin/members.py +0 -0
  29. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/admin/registry.py +0 -0
  30. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/api/__init__.py +0 -0
  31. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/api/routes.py +0 -0
  32. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/app_config.py +0 -0
  33. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/auth/__init__.py +0 -0
  34. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/auth/oauth.py +0 -0
  35. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/auth/utils.py +0 -0
  36. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/bridge.py +0 -0
  37. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/database.py +0 -0
  38. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/extension.py +0 -0
  39. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/extensions.py +0 -0
  40. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/health.py +0 -0
  41. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/hooks.py +0 -0
  42. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/middleware.py +0 -0
  43. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/models.py +0 -0
  44. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/py.typed +0 -0
  45. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/sdk.py +0 -0
  46. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/security.py +0 -0
  47. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/security_scanner.py +0 -0
  48. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/css/app.css +0 -0
  49. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/fonts.css +0 -0
  50. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
  51. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
  52. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
  53. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
  54. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
  55. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
  56. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
  57. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
  58. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
  59. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
  60. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
  61. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
  62. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
  63. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
  64. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
  65. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
  66. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
  67. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
  68. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
  69. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
  70. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
  71. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
  72. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
  73. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
  74. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
  75. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
  76. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
  77. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
  78. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
  79. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
  80. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
  81. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
  82. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
  83. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
  84. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
  85. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
  86. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/static/vendor/htmx/htmx.min.js +0 -0
  87. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/admin/_member_drawer.html +0 -0
  88. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/admin/_members_table.html +0 -0
  89. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/admin/flairs.html +0 -0
  90. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/admin/permissions.html +0 -0
  91. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/admin/roles.html +0 -0
  92. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/admin/users.html +0 -0
  93. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/auth/login.html +0 -0
  94. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/auth/profile.html +0 -0
  95. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/base.html +0 -0
  96. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/errors/401.html +0 -0
  97. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/errors/403.html +0 -0
  98. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/errors/404.html +0 -0
  99. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/errors/500.html +0 -0
  100. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager/templates/index.html +0 -0
  101. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager_server.egg-info/dependency_links.txt +0 -0
  102. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager_server.egg-info/entry_points.txt +0 -0
  103. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager_server.egg-info/requires.txt +0 -0
  104. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/appmanager_server.egg-info/top_level.txt +0 -0
  105. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/requirements.txt +0 -0
  106. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/setup.cfg +0 -0
  107. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_api.py +0 -0
  108. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_app_settings.py +0 -0
  109. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_app_settings_config.py +0 -0
  110. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_cli_dev_and_templates.py +0 -0
  111. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_cli_health.py +0 -0
  112. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_cli_scaffolding.py +0 -0
  113. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_error_pages.py +0 -0
  114. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_extension_apps.py +0 -0
  115. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_extension_pattern.py +0 -0
  116. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_hooks.py +0 -0
  117. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_installer.py +0 -0
  118. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_invalid_install_failures.py +0 -0
  119. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_manifest_sdk.py +0 -0
  120. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_middleware.py +0 -0
  121. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_role_management.py +0 -0
  122. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_sdk.py +0 -0
  123. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_security_hardening.py +0 -0
  124. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_security_precheck_flow.py +0 -0
  125. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_security_scanner.py +0 -0
  126. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_signals.py +0 -0
  127. {appmanager_server-0.2.0 → appmanager_server-0.3.0}/tests/test_user_elevation_and_visibility.py +0 -0
  128. {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 (Optional - logs to terminal console if left blank)
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,5 +1,6 @@
1
1
  include LICENSE
2
2
  include README.md
3
+ include CHANGELOG.md
3
4
  include SECURITY.md
4
5
  include requirements.txt
5
6
  include .env.example
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: appmanager-server
3
- Version: 0.2.0
4
- Summary: Multi-tenant Flask sub-application portal & dynamic WSGI dispatcher with in-process isolation, telemetry, and automated health checks.
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 |
@@ -8,7 +8,7 @@ from appmanager.database import db
8
8
  from appmanager.extension import AppManager
9
9
  from appmanager.middleware import DynamicAppDispatcherMiddleware
10
10
 
11
- __version__ = "0.2.0"
11
+ __version__ = "0.3.0"
12
12
 
13
13
  __all__ = [
14
14
  "AppManager",
@@ -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) -> None:
231
- req_file = os.path.join(app_dir, "requirements.txt")
232
- if os.path.exists(req_file):
233
- try:
234
- print(f"[INSTALLER] Installing dependencies for sub-app at {app_dir}...")
235
- subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", req_file])
236
- print("[INSTALLER] Sub-app dependencies installed successfully.")
237
- except Exception as e:
238
- print(f"[INSTALLER WARNING] Failed to install requirements.txt: {e}")
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 or callable create_app factory
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 callable(app_obj) and not hasattr(app_obj, "wsgi_app"):
274
- # Factory function e.g. create_app()
275
- app_instance = app_obj()
276
- else:
277
- app_instance = app_obj
278
- return app_instance
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
- target_dir = os.path.join(current_app.config["INSTALLED_APPS_DIR"], slug)
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
- return True, "App uninstalled successfully."
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}"