shareddata 6.85.14__tar.gz → 6.85.15__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.
- {shareddata-6.85.14/src/shareddata.egg-info → shareddata-6.85.15}/PKG-INFO +1 -1
- {shareddata-6.85.14 → shareddata-6.85.15}/setup.py +1 -1
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/JobStore.py +2 -2
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/WorkerLib.py +38 -1
- shareddata-6.85.15/src/SharedData/Web/mongo_view.py +399 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/proc.py +86 -9
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/standalone.py +2 -1
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/standalone.css +18 -3
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/standalone.js +120 -24
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/standalone.html +3 -2
- {shareddata-6.85.14 → shareddata-6.85.15/src/shareddata.egg-info}/PKG-INFO +1 -1
- shareddata-6.85.14/src/SharedData/Web/mongo_view.py +0 -125
- {shareddata-6.85.14 → shareddata-6.85.15}/LICENSE +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/MANIFEST.in +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/README.md +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/pyproject.toml +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/setup.cfg +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/ServerGunicorn.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/ServerWaitress.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/__init__.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/apidocs-dark.css +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/apidocs.yml +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/auth.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/constants.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/download_paths.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/download_slots.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/install/worker.ps1 +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/install/worker.sh +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/listcache.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/__init__.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/cache.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/collections.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/data.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/downloads.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/metadata.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/operations.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/schedules.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/sqlite.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/system.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/tables.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/timeseries.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/web_routes.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/routes/workers.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/API/utils.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Auth/__init__.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Auth/bootstrap.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Auth/migrate.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Auth/store.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Auth/workers.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/CacheRedis.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/CollectionMongoDB.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/DataAPI/__init__.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/DataAPI/catalog.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/DataAPI/config.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/DataAPI/errors.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/DataAPI/indexes.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/DataAPI/readers.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/DataAPI/resolve.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/DataAPI/service.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Database.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Defaults.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/AWSEC2.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/AWSS3.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/AutoDocstrings.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/ClientAPI.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/ClientSocket.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/ClientWebSocket.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/LogHandlerAPI.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/LogReader.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/MongoDBClient.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/SaveTables.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/ServerSocket.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/ServerWebSocket.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/StreamsCache.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/StreamsPersist.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/SyncTable.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/TunnelWebSocket.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/IO/__init__.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Listing.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/LockWaiters.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Logger.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/MCP/__init__.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/MCP/__main__.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/MCP/client.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/MCP/server.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Metadata.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/MultiProc.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/OpenFIGI.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Redaction.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/ArchiveOldCollections.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/BatchJob.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/ContainerHealth.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/RoutineCommand.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/RoutineOrigin.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/Schedule.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/ScheduleMirror.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/ScheduleMonitor.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/ScheduleStore.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/Scheduler.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/UpdateActiveJobs.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/Worker.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/WorkerBootstrap.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/WorkerPool.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Routines/__init__.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/SQLite.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/SharedData.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/SharedNumpy.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/StreamKafka.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Symbol.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Table.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableDisk.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableIndex.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableIndexJitFunctions.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableIndexJitFunctionsManual.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableIndexJitGenerate.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableIndexJitHash.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableIndexJitLoc.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableProtocol.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableRead.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TableRewrite.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TimeSeriesDisk.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/TimeseriesContainer.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Users.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Utils.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/ADDING_A_PAGE.md +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/__init__.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/activity_logger.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/app.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/assets/dashAgGridComponentFunctions.js +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/assets/dashAgGridFunctions.js +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/assets/google_logo.png +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/assets/kafka_keys.js +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/assets/logs.css +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/assets/sched.css +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/assets/styles.css +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/auth.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/backends.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/config.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/containers.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/identity.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/kafka_view.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/redis_view.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/route_health.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/route_registry.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/sched.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/schedule_editor.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/server.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/services.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/shell.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/account.js +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/custom.css +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/favicon.ico +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/favicon.png +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/logo.png +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/logo_96x96.png +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/shareddata-mark.svg +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/shell.css +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/static/shell.js +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/suggest.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/account.html +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/base.html +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/claim.html +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/claim_done.html +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/dash.html +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/index.html +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/login.html +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/login_2fa.html +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/partials/navbar.html +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/partials/scripts.html +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/recovery_codes.html +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/templates/shell.html +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/twofactor.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/utils.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/Web/workerpool.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/__init__.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/build_info.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/fleet.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/preflight.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/private_files.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/release_backup.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/server.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/services.py +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/SharedData/sharedmutexwin.pyd +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/shareddata.egg-info/SOURCES.txt +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/shareddata.egg-info/dependency_links.txt +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/shareddata.egg-info/entry_points.txt +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/shareddata.egg-info/requires.txt +0 -0
- {shareddata-6.85.14 → shareddata-6.85.15}/src/shareddata.egg-info/top_level.txt +0 -0
|
@@ -285,7 +285,7 @@ def active(now=None):
|
|
|
285
285
|
|
|
286
286
|
|
|
287
287
|
# What a list of jobs can be grouped by: the columns (SQL) that make a group, by name.
|
|
288
|
-
GROUPS = {'time': ('minute', 'repo', 'branch', 'routine'), 'routine': ('repo', 'branch', 'routine'), 'status': ('status',), 'computer': ('computer',)}
|
|
288
|
+
GROUPS = {'time': ('minute', 'repo', 'branch', 'routine'), 'routine': ('repo', 'branch', 'routine'), 'repo': ('repo',), 'status': ('status',), 'computer': ('computer',)}
|
|
289
289
|
_GROUP_SQL = {'minute': 'substr(date, 1, 16)', 'repo': "COALESCE(repo, '')", 'branch': "COALESCE(branch, '')", 'routine': "COALESCE(routine, '')",
|
|
290
290
|
'status': 'status', 'computer': "COALESCE(target, computer, '')"}
|
|
291
291
|
|
|
@@ -333,7 +333,7 @@ def find(month=None, statuses=None, text=None, limit=200, offset=0, now=None, gr
|
|
|
333
333
|
|
|
334
334
|
def groups(by='time', month=None, statuses=None, text=None, limit=500, now=None):
|
|
335
335
|
"""The groups of a list without their jobs: how many jobs of each status every group has.
|
|
336
|
-
by: 'time' (the minute a job is due, with its routine), 'routine', 'status' or 'computer'.
|
|
336
|
+
by: 'time' (the minute a job is due, with its routine), 'routine', 'repo', 'status' or 'computer'.
|
|
337
337
|
-> {'groups': [{'key': {...}, 'counts': {status: n}, 'total': n}], 'groups_total': n,
|
|
338
338
|
'total': n, 'counts': {status: n}, 'months': [...]}; oldest minute first, then by name."""
|
|
339
339
|
if by not in GROUPS:
|
|
@@ -859,6 +859,31 @@ def install_repo(command, quiet=False):
|
|
|
859
859
|
installed_repo['isinstalling'] = False
|
|
860
860
|
|
|
861
861
|
|
|
862
|
+
def _git_text(args, timeout=30):
|
|
863
|
+
"""stdout of a git command, or None when it failed or timed out (a missing or unreachable server)."""
|
|
864
|
+
try:
|
|
865
|
+
done = subprocess.run(['git', *args], capture_output=True, text=True, timeout=timeout)
|
|
866
|
+
except (OSError, subprocess.SubprocessError):
|
|
867
|
+
return None
|
|
868
|
+
return done.stdout.strip() if done.returncode == 0 else None
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _repo_is_current(repo_path, url, ref):
|
|
872
|
+
"""True when the clone is on `ref` at exactly the commit the server has for it.
|
|
873
|
+
|
|
874
|
+
One `git ls-remote` (a reference list, no objects) replaces the checkout and the pull of every start:
|
|
875
|
+
code is exactly as new as before, and nothing runs, logs or changes when nothing was pushed. Anything
|
|
876
|
+
that cannot be proven (server unreachable, branch gone, other branch checked out) is not 'current'
|
|
877
|
+
and takes the full checkout + pull path, with its retries and its fallback to the installed copy.
|
|
878
|
+
"""
|
|
879
|
+
remote = _git_text(['ls-remote', url, 'refs/heads/' + ref])
|
|
880
|
+
if not remote:
|
|
881
|
+
return False
|
|
882
|
+
remote_sha = remote.split()[0]
|
|
883
|
+
return (_git_text(['-C', str(repo_path), 'rev-parse', 'HEAD']) == remote_sha
|
|
884
|
+
and _git_text(['-C', str(repo_path), 'rev-parse', '--abbrev-ref', 'HEAD']) == ref)
|
|
885
|
+
|
|
886
|
+
|
|
862
887
|
def _install_repo(command, quiet=False):
|
|
863
888
|
|
|
864
889
|
"""
|
|
@@ -901,7 +926,19 @@ def _install_repo(command, quiet=False):
|
|
|
901
926
|
os.environ['GIT_ACRONYM']+'/'+command['repo']
|
|
902
927
|
|
|
903
928
|
# GIT PULL OR GIT CLONE
|
|
904
|
-
|
|
929
|
+
current = (repo_exists and venv_exists and requirements_path.is_file()
|
|
930
|
+
and _repo_is_current(repo_path, GIT_URL, command['branch'] if hasbranch else 'main'))
|
|
931
|
+
if repo_exists and current:
|
|
932
|
+
Logger.log.debug('Repo %s is up to date' % (command['repo']))
|
|
933
|
+
pulled = True # the code is as new as the server's: the requirements check below still applies
|
|
934
|
+
runroutine = True
|
|
935
|
+
install_requirements = False
|
|
936
|
+
if requirements_path.is_file():
|
|
937
|
+
venv_root = repo_path / "venv"
|
|
938
|
+
new_hash = hash_file(requirements_path)
|
|
939
|
+
stored_hash = load_last_hash(venv_root)
|
|
940
|
+
install_requirements = (new_hash is None or stored_hash is None or new_hash != stored_hash)
|
|
941
|
+
elif repo_exists:
|
|
905
942
|
if not quiet:
|
|
906
943
|
Logger.log.info('Pulling repo %s' % (command['repo']))
|
|
907
944
|
requirements_lastmod = 0
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""The MongoDB this server uses, as the MONGO page shows it: every server it is told of (and,
|
|
2
|
+
behind a router, every router of the cluster), the members of its replica set or the shards
|
|
3
|
+
behind its routers, its databases, and what is wrong.
|
|
4
|
+
|
|
5
|
+
Read-only commands, on connections of the page's own with short limits. Each server is asked
|
|
6
|
+
directly and at the same time (one connection to that server only), so the numbers of a row are
|
|
7
|
+
that server's, and a server that is down is reported instead of waited for.
|
|
8
|
+
"""
|
|
9
|
+
import hashlib
|
|
10
|
+
import ipaddress
|
|
11
|
+
import re
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor, TimeoutError as Late, wait as wait_for
|
|
15
|
+
from datetime import timezone
|
|
16
|
+
|
|
17
|
+
WAIT = 7
|
|
18
|
+
BEHIND = 60 # seconds a secondary may be behind before it is pointed out
|
|
19
|
+
SERVER_SECONDS = 2.5 # a server that takes longer is reported as not answering
|
|
20
|
+
FORGOTTEN = 3600 # a router the cluster has not heard of for longer is taken as retired
|
|
21
|
+
PORT = 27017
|
|
22
|
+
MAX_PENDING = 64 # includes running and queued work across all page requests
|
|
23
|
+
MAX_SERVERS = 64 # bound cluster-controlled discovery and candidate scans
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def configured():
|
|
27
|
+
return bool(_env().get('MONGODB_HOST'))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _env():
|
|
31
|
+
from SharedData.Web import services
|
|
32
|
+
return services.values('mongodb')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _seeds(env):
|
|
36
|
+
"""host:port of every server MONGODB_HOST names, in its order."""
|
|
37
|
+
seeds = []
|
|
38
|
+
for part in (env.get('MONGODB_HOST') or '').split(','):
|
|
39
|
+
part = part.strip()
|
|
40
|
+
if not part:
|
|
41
|
+
continue
|
|
42
|
+
host, sep, port = part.rpartition(':')
|
|
43
|
+
address = part if sep and host and port.isdigit() else '%s:%d' % (part, PORT)
|
|
44
|
+
if address not in seeds:
|
|
45
|
+
seeds.append(address)
|
|
46
|
+
return seeds
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _server_uri(env, address):
|
|
50
|
+
"""The address of one server alone. Without replicaSet: with it the driver finds the set
|
|
51
|
+
again and sends the commands to its primary, whichever server was asked for."""
|
|
52
|
+
from SharedData.Web.services import mongo_uri
|
|
53
|
+
one = {name: value for name, value in env.items() if name != 'MONGODB_REPLICA_SET'}
|
|
54
|
+
one['MONGODB_HOST'] = address
|
|
55
|
+
uri = mongo_uri(one)
|
|
56
|
+
return uri + ('&' if '?' in uri else '?') + 'directConnection=true'
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _named(host, address):
|
|
60
|
+
"""name:port as a server gives its own name (serverStatus says the name without the port)."""
|
|
61
|
+
if not host:
|
|
62
|
+
return ''
|
|
63
|
+
return host if ':' in host else '%s:%s' % (host, address.rpartition(':')[2])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _ask(address, env):
|
|
67
|
+
from pymongo import timeout
|
|
68
|
+
with timeout(SERVER_SECONDS): # one budget for selection, hello and serverStatus together
|
|
69
|
+
return _ask_server(address, env)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _ask_server(address, env):
|
|
73
|
+
"""What one server says of itself, or why it does not answer. -> {'address', 'host', 'role',
|
|
74
|
+
'set', 'answers', 'error', 'seconds', 'version', 'uptime', 'connections', 'connections_free',
|
|
75
|
+
'memory_mb', 'operations'}"""
|
|
76
|
+
from pymongo import MongoClient
|
|
77
|
+
started = time.time()
|
|
78
|
+
out = {'address': address, 'host': '', 'role': '', 'set': None, 'answers': False, 'error': None, 'seconds': None, 'version': None, 'uptime': None,
|
|
79
|
+
'connections': None, 'connections_free': None, 'memory_mb': None, 'operations': {}}
|
|
80
|
+
limit = int(SERVER_SECONDS * 1000)
|
|
81
|
+
try:
|
|
82
|
+
client = MongoClient(_server_uri(env, address), serverSelectionTimeoutMS=limit, connectTimeoutMS=limit, socketTimeoutMS=limit)
|
|
83
|
+
try:
|
|
84
|
+
hello = client.admin.command('hello')
|
|
85
|
+
out.update(answers=True, set=hello.get('setName'),
|
|
86
|
+
role='router' if hello.get('msg') == 'isdbgrid' else 'primary' if hello.get('setName') and hello.get('isWritablePrimary')
|
|
87
|
+
else 'secondary' if hello.get('secondary') else 'arbiter' if hello.get('arbiterOnly') else 'member' if hello.get('setName') else 'single')
|
|
88
|
+
try:
|
|
89
|
+
status = client.admin.command('serverStatus')
|
|
90
|
+
out.update(host=_named(status.get('host'), address), version=status.get('version'), uptime=status.get('uptime'),
|
|
91
|
+
connections=status.get('connections', {}).get('current'), connections_free=status.get('connections', {}).get('available'),
|
|
92
|
+
memory_mb=status.get('mem', {}).get('resident'),
|
|
93
|
+
operations={name: status.get('opcounters', {}).get(name) for name in ('insert', 'query', 'update', 'delete', 'command')})
|
|
94
|
+
except Exception as exc: # it answers, but this user may not read its numbers
|
|
95
|
+
out['error'] = 'its numbers cannot be read (%s: %s)' % (type(exc).__name__, str(exc)[:120])
|
|
96
|
+
finally:
|
|
97
|
+
client.close()
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
out['error'] = '%s: %s' % (type(exc).__name__, str(exc).split(' (configured timeouts')[0].split(', Timeout:')[0][:160])
|
|
100
|
+
out['seconds'] = round(time.time() - started, 3)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _locate(name, candidates, env):
|
|
105
|
+
"""Router names are labels, never network destinations. Probe only the validated cluster
|
|
106
|
+
addresses passed by _servers and require the server's own identity and router role."""
|
|
107
|
+
from pymongo import timeout
|
|
108
|
+
started = time.monotonic()
|
|
109
|
+
with timeout(SERVER_SECONDS):
|
|
110
|
+
for address in candidates[:MAX_SERVERS]:
|
|
111
|
+
if time.monotonic() - started >= SERVER_SECONDS:
|
|
112
|
+
break
|
|
113
|
+
reply = _ask(address, env)
|
|
114
|
+
if reply.get('answers') and reply.get('host') == name and reply.get('role') == 'router':
|
|
115
|
+
return reply
|
|
116
|
+
return {'address': '', 'host': name, 'role': 'router', 'set': None, 'answers': False, 'seconds': round(time.monotonic() - started, 3), 'version': None, 'uptime': None,
|
|
117
|
+
'connections': None, 'connections_free': None, 'memory_mb': None, 'operations': {},
|
|
118
|
+
'error': 'no approved address of the cluster answers as this router'}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _candidates(name, hosts, seeds):
|
|
122
|
+
# config.mongos is writable metadata: it cannot choose a port, URI, DNS target or IP.
|
|
123
|
+
# getShardMap comes from the authenticated cluster command, not from config.mongos.
|
|
124
|
+
# Auto-discovery is restricted to numeric private unicast addresses and configured ports.
|
|
125
|
+
if not isinstance(name, str) or not re.fullmatch(r'[A-Za-z0-9_.-]{1,253}:[0-9]{1,5}', name):
|
|
126
|
+
return ()
|
|
127
|
+
port = name.rpartition(':')[2]
|
|
128
|
+
if port not in {seed.rpartition(':')[2] for seed in seeds} or not 0 < int(port) < 65536:
|
|
129
|
+
return ()
|
|
130
|
+
addresses = []
|
|
131
|
+
for host in hosts[:MAX_SERVERS]:
|
|
132
|
+
try:
|
|
133
|
+
ip = ipaddress.ip_address(host)
|
|
134
|
+
except ValueError:
|
|
135
|
+
continue
|
|
136
|
+
if (ip.version != 4 or not ip.is_private or ip.is_loopback or ip.is_link_local
|
|
137
|
+
or ip.is_multicast or ip.is_unspecified or ip.is_reserved):
|
|
138
|
+
continue
|
|
139
|
+
address = '%s:%s' % (ip, port)
|
|
140
|
+
if address not in addresses and address not in seeds:
|
|
141
|
+
addresses.append(address)
|
|
142
|
+
return tuple(addresses)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _identity(env):
|
|
146
|
+
# Separate in-flight work and cached identities when credentials/settings change.
|
|
147
|
+
return hashlib.sha256(repr(sorted(env.items())).encode()).hexdigest()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
_pool = ThreadPoolExecutor(max_workers=16, thread_name_prefix='mongo-view')
|
|
151
|
+
_pending = {} # what is asked -> a question still without an answer (never asked twice at once)
|
|
152
|
+
_pending_lock = threading.Lock()
|
|
153
|
+
_names = {} # address -> the name it last answered as, so a server that stops answering keeps its name
|
|
154
|
+
_located = {} # a router's name that does not resolve here -> the address that answered as it
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _submit(questions):
|
|
158
|
+
"""{key: (function, args)} -> {key: future}, a question already on its way not asked again."""
|
|
159
|
+
with _pending_lock:
|
|
160
|
+
for key, future in list(_pending.items()):
|
|
161
|
+
if future.done():
|
|
162
|
+
_pending.pop(key, None)
|
|
163
|
+
asked = {}
|
|
164
|
+
for key, (function, args) in questions.items():
|
|
165
|
+
future = _pending.get(key)
|
|
166
|
+
if future is None and len(_pending) < MAX_PENDING:
|
|
167
|
+
future = _pool.submit(function, *args)
|
|
168
|
+
_pending[key] = future
|
|
169
|
+
asked[key] = future # saturated: an explicit no-answer, no new task
|
|
170
|
+
return asked
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _collect(asked, until):
|
|
174
|
+
"""The replies in by *until* (time.monotonic()); a question not answered by then is reported as
|
|
175
|
+
such and left to end by itself. -> {key: reply}"""
|
|
176
|
+
wait_for([future for future in asked.values() if future is not None], timeout=max(until - time.monotonic(), 0))
|
|
177
|
+
replies = {}
|
|
178
|
+
with _pending_lock:
|
|
179
|
+
for key, future in asked.items():
|
|
180
|
+
if future is not None and future.done():
|
|
181
|
+
if _pending.get(key) is future:
|
|
182
|
+
_pending.pop(key, None)
|
|
183
|
+
try:
|
|
184
|
+
replies[key] = future.result()
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
replies[key] = {'answers': False, 'error': '%s: %s' % (type(exc).__name__, str(exc)[:160])}
|
|
187
|
+
else:
|
|
188
|
+
replies[key] = {'answers': False, 'error': 'no answer in %g seconds' % SERVER_SECONDS}
|
|
189
|
+
return replies
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _read(seconds):
|
|
193
|
+
from pymongo import timeout
|
|
194
|
+
with timeout(seconds): # all cluster commands and dbStats share this deadline
|
|
195
|
+
return _read_cluster(seconds)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _read_cluster(seconds):
|
|
199
|
+
"""What the cluster says of itself, through the connection SharedData uses."""
|
|
200
|
+
from pymongo import MongoClient
|
|
201
|
+
from SharedData.Web.services import mongo_uri
|
|
202
|
+
limit = seconds * 1000
|
|
203
|
+
client = MongoClient(mongo_uri(_env()), serverSelectionTimeoutMS=limit, connectTimeoutMS=limit, socketTimeoutMS=limit)
|
|
204
|
+
try:
|
|
205
|
+
hello = client.admin.command('hello')
|
|
206
|
+
out = {'kind': 'sharded' if hello.get('msg') == 'isdbgrid' else 'replica set' if hello.get('setName') else 'single', 'set': hello.get('setName'),
|
|
207
|
+
'members': [], 'shards': [], 'routers': [], 'cluster_hosts': []}
|
|
208
|
+
if out['kind'] == 'replica set':
|
|
209
|
+
try:
|
|
210
|
+
members = client.admin.command('replSetGetStatus').get('members', [])
|
|
211
|
+
primary = next((member for member in members if member.get('stateStr') == 'PRIMARY'), None)
|
|
212
|
+
for member in members:
|
|
213
|
+
lag = None
|
|
214
|
+
if primary and member.get('stateStr') == 'SECONDARY' and member.get('optimeDate') and primary.get('optimeDate'):
|
|
215
|
+
lag = max((primary['optimeDate'] - member['optimeDate']).total_seconds(), 0)
|
|
216
|
+
out['members'].append({'name': member.get('name'), 'state': member.get('stateStr'), 'healthy': member.get('health') == 1, 'lag': lag,
|
|
217
|
+
'uptime': member.get('uptime'), 'copies_from': member.get('syncSourceHost') or '', 'message': member.get('lastHeartbeatMessage') or ''})
|
|
218
|
+
except Exception: # this user may not read the set's status: what the server says of it
|
|
219
|
+
out['members'] = [{'name': name, 'state': 'PRIMARY' if name == hello.get('primary') else 'SECONDARY' if name in hello.get('hosts', []) else 'ARBITER',
|
|
220
|
+
'healthy': True, 'lag': None, 'uptime': None, 'copies_from': '', 'message': ''}
|
|
221
|
+
for name in list(hello.get('hosts', [])) + list(hello.get('arbiters', []))]
|
|
222
|
+
out['limited'] = True
|
|
223
|
+
elif out['kind'] == 'sharded':
|
|
224
|
+
out['shards'] = [{'name': shard.get('_id'), 'servers': shard.get('host'), 'ready': shard.get('state', 1) == 1} for shard in client.admin.command('listShards').get('shards', [])]
|
|
225
|
+
try: # every router of the cluster, as the routers report themselves
|
|
226
|
+
now = time.time() # the driver gives the ping in UTC, without its zone
|
|
227
|
+
out['routers'] = [{'name': router['_id'], 'heard': max(now - router['ping'].replace(tzinfo=timezone.utc).timestamp(), 0) if router.get('ping') else None,
|
|
228
|
+
'version': router.get('mongoVersion')} for router in client.config.mongos.find({}, {'_id': 1, 'ping': 1, 'mongoVersion': 1}).limit(MAX_SERVERS)]
|
|
229
|
+
except Exception:
|
|
230
|
+
out['routers'] = []
|
|
231
|
+
try: # where the cluster's servers are, for a router whose name does not resolve here
|
|
232
|
+
hosts = set()
|
|
233
|
+
for servers in client.admin.command('getShardMap').get('map', {}).values():
|
|
234
|
+
for address in servers.rpartition('/')[2].split(','):
|
|
235
|
+
if address:
|
|
236
|
+
hosts.add(address.rpartition(':')[0])
|
|
237
|
+
out['cluster_hosts'] = sorted(hosts)
|
|
238
|
+
except Exception:
|
|
239
|
+
out['cluster_hosts'] = sorted({address.rpartition(':')[0] for shard in out['shards'] for address in str(shard['servers'] or '').rpartition('/')[2].split(',') if address})
|
|
240
|
+
databases = []
|
|
241
|
+
for database in client.admin.command('listDatabases').get('databases', []):
|
|
242
|
+
row = {'name': database['name'], 'size': database.get('sizeOnDisk'), 'collections': None, 'documents': None, 'indexes': None}
|
|
243
|
+
if len(databases) < 40:
|
|
244
|
+
try:
|
|
245
|
+
stats = client[database['name']].command('dbStats')
|
|
246
|
+
row.update(collections=stats.get('collections'), documents=stats.get('objects'), indexes=stats.get('indexes'), data=stats.get('dataSize'), index_size=stats.get('indexSize'))
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
databases.append(row)
|
|
250
|
+
out['databases'] = sorted(databases, key=lambda row: -(row['size'] or 0))
|
|
251
|
+
return out
|
|
252
|
+
finally:
|
|
253
|
+
client.close()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _row(reply, address, host, listed, heard):
|
|
257
|
+
row = {'address': address, 'host': host, 'role': '', 'set': None, 'answers': False, 'error': None, 'seconds': None, 'version': None, 'uptime': None,
|
|
258
|
+
'connections': None, 'connections_free': None, 'memory_mb': None, 'operations': {}}
|
|
259
|
+
row.update({name: value for name, value in reply.items() if name in row})
|
|
260
|
+
row['address'], row['host'] = row['address'] or address, row['host'] or host
|
|
261
|
+
row.update(listed=listed, heard=None if heard is None else round(heard))
|
|
262
|
+
return row
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _servers(env, seeds, asked, found, started):
|
|
266
|
+
"""One row per server: those MONGODB_HOST names, then the cluster's other routers. A router
|
|
267
|
+
the cluster has not heard of for an hour is left out (config.mongos keeps every router that
|
|
268
|
+
ever ran). Unlocated names may belong to unnamed down seeds, so they are diagnostics
|
|
269
|
+
rather than extra server counts in that case. -> (rows, retired count, unlocated names)"""
|
|
270
|
+
replies = _collect(asked, started + SERVER_SECONDS + 1.5)
|
|
271
|
+
identity = _identity(env)
|
|
272
|
+
routers = {router['name']: router for router in (found or {}).get('routers', [])[:MAX_SERVERS] if isinstance(router.get('name'), str)}
|
|
273
|
+
rows = []
|
|
274
|
+
for address in seeds:
|
|
275
|
+
reply = replies[(identity, address)]
|
|
276
|
+
host = reply.get('host') or _names.get((identity, address), '')
|
|
277
|
+
if reply.get('answers') and reply.get('host'):
|
|
278
|
+
_names[(identity, address)] = reply['host']
|
|
279
|
+
row = _row(reply, address, host, 'configured', routers.get(host, {}).get('heard'))
|
|
280
|
+
same = next((i for i, other in enumerate(rows) if host and other['host'] == host), None)
|
|
281
|
+
if same is None:
|
|
282
|
+
rows.append(row)
|
|
283
|
+
elif row['answers'] and not rows[same]['answers']:
|
|
284
|
+
rows[same] = row
|
|
285
|
+
known = {row['host'] for row in rows if row['host']}
|
|
286
|
+
others = [router for name, router in sorted(routers.items()) if name not in known]
|
|
287
|
+
left_out = [router for router in others if router['heard'] is not None and router['heard'] > FORGOTTEN]
|
|
288
|
+
others = [router for router in others if router not in left_out]
|
|
289
|
+
unlocated = []
|
|
290
|
+
if others:
|
|
291
|
+
hosts = (found or {}).get('cluster_hosts', [])
|
|
292
|
+
questions = {}
|
|
293
|
+
for router in others:
|
|
294
|
+
candidates = list(_candidates(router['name'], hosts, seeds))
|
|
295
|
+
located_key = (identity, router['name'])
|
|
296
|
+
if _located.get(located_key) in candidates: # where it answered the last time first
|
|
297
|
+
candidates.remove(_located[located_key])
|
|
298
|
+
candidates.insert(0, _located[located_key])
|
|
299
|
+
questions[(identity, 'router|' + router['name'])] = (_locate, (router['name'], tuple(candidates), env))
|
|
300
|
+
replies = _collect(_submit(questions), started + WAIT - 0.25)
|
|
301
|
+
for router in others:
|
|
302
|
+
reply = replies[(identity, 'router|' + router['name'])]
|
|
303
|
+
if not reply.get('answers') and any(not row['host'] for row in rows):
|
|
304
|
+
unlocated.append(router['name'])
|
|
305
|
+
continue # identity unknown: do not count a down seed twice or invent an address
|
|
306
|
+
if reply.get('answers') and reply.get('address') and reply['address'] != router['name']:
|
|
307
|
+
_located[(identity, router['name'])] = reply['address']
|
|
308
|
+
rows.append(_row(dict(reply, host=router['name'], role=reply.get('role') or 'router'), reply.get('address') or _located.get((identity, router['name']), ''),
|
|
309
|
+
router['name'], 'cluster', router['heard']))
|
|
310
|
+
return rows, len(left_out), unlocated
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def view():
|
|
314
|
+
"""-> {'configured', 'state', 'kind', 'summary', 'members', 'shards', 'servers', 'databases', 'problems', 'seconds'}
|
|
315
|
+
'servers' is one row per server, as that server answers for itself: {'address' (where it is
|
|
316
|
+
asked), 'host' (the name it gives), 'role', 'set', 'answers', 'error', 'seconds', 'version',
|
|
317
|
+
'uptime', 'connections', 'connections_free', 'memory_mb', 'operations', 'listed'
|
|
318
|
+
('configured': MONGODB_HOST names it, 'cluster': a router the cluster lists), 'heard'
|
|
319
|
+
(seconds since the cluster last heard of that router)}."""
|
|
320
|
+
started = time.monotonic()
|
|
321
|
+
empty = {'kind': '', 'summary': {}, 'members': [], 'shards': [], 'servers': [], 'databases': [], 'problems': []}
|
|
322
|
+
if not configured():
|
|
323
|
+
return dict(empty, configured=False, state='none', seconds=0)
|
|
324
|
+
env = _env()
|
|
325
|
+
seeds = _seeds(env)
|
|
326
|
+
identity = _identity(env)
|
|
327
|
+
cluster_key = (identity, 'cluster')
|
|
328
|
+
cluster = _submit({cluster_key: (_read, (WAIT - 3,))})[cluster_key]
|
|
329
|
+
asked = _submit({(identity, address): (_ask, (address, env)) for address in seeds})
|
|
330
|
+
try:
|
|
331
|
+
if cluster is None:
|
|
332
|
+
raise Late()
|
|
333
|
+
found = cluster.result(timeout=max(0, started + WAIT - 3 - time.monotonic()))
|
|
334
|
+
except Late:
|
|
335
|
+
why = 'no answer in %g seconds' % (WAIT - 3)
|
|
336
|
+
found = None
|
|
337
|
+
except Exception as exc:
|
|
338
|
+
why, found = '%s: %s' % (type(exc).__name__, str(exc).split(', Timeout:')[0][:200]), None
|
|
339
|
+
finally:
|
|
340
|
+
with _pending_lock:
|
|
341
|
+
if cluster is not None and cluster.done() and _pending.get(cluster_key) is cluster:
|
|
342
|
+
_pending.pop(cluster_key, None)
|
|
343
|
+
servers, left_out, unlocated = _servers(env, seeds, asked, found, started)
|
|
344
|
+
answering = [row for row in servers if row['answers']]
|
|
345
|
+
counts = {'addresses': env.get('MONGODB_HOST'), 'servers': len(servers), 'answering': len(answering),
|
|
346
|
+
'versions': sorted({row['version'] for row in answering if row['version']})}
|
|
347
|
+
if found is None:
|
|
348
|
+
return dict(empty, configured=True, state='down', seconds=round(time.monotonic() - started, 3), summary=counts, servers=servers,
|
|
349
|
+
problems=[{'level': 'error', 'text': 'MongoDB does not answer at %s (%s)' % (env.get('MONGODB_HOST'), why)}])
|
|
350
|
+
problems = []
|
|
351
|
+
if found['kind'] == 'replica set':
|
|
352
|
+
if not any(member['state'] == 'PRIMARY' for member in found['members']):
|
|
353
|
+
problems.append({'level': 'error', 'text': 'the replica set has no primary: nothing can be written'})
|
|
354
|
+
for member in found['members']:
|
|
355
|
+
if not member['healthy'] or member['state'] not in ('PRIMARY', 'SECONDARY', 'ARBITER'):
|
|
356
|
+
problems.append({'level': 'warning', 'text': '%s is %s%s' % (member['name'], (member['state'] or 'not healthy').lower(), ': ' + member['message'] if member['message'] else '')})
|
|
357
|
+
elif member['lag'] is not None and member['lag'] > BEHIND:
|
|
358
|
+
problems.append({'level': 'warning', 'text': '%s is %d seconds behind the primary' % (member['name'], member['lag'])})
|
|
359
|
+
holding = [member for member in found['members'] if member['state'] in ('PRIMARY', 'SECONDARY')]
|
|
360
|
+
if len(holding) == 1 and not found.get('limited'):
|
|
361
|
+
problems.append({'level': 'note', 'text': 'the data is on one member only (%s): the set has no second copy' % holding[0]['name']})
|
|
362
|
+
for shard in found['shards']:
|
|
363
|
+
if not shard['ready']:
|
|
364
|
+
problems.append({'level': 'error', 'text': 'shard %s is not ready' % shard['name']})
|
|
365
|
+
for row in servers:
|
|
366
|
+
name = row['host'] or row['address']
|
|
367
|
+
at = ' at ' + row['address'] if row['host'] and row['address'] and row['address'] != row['host'] else ''
|
|
368
|
+
if not row['answers']:
|
|
369
|
+
problems.append({'level': 'error', 'text': '%s%s does not answer (%s)' % (name, at, row['error'])})
|
|
370
|
+
elif row['error']:
|
|
371
|
+
problems.append({'level': 'warning', 'text': '%s answers, but %s' % (name, row['error'])})
|
|
372
|
+
free = row['connections_free']
|
|
373
|
+
if free is not None and row['connections'] and free < row['connections'] * 0.1:
|
|
374
|
+
problems.append({'level': 'warning', 'text': '%s has few connections left: %d in use, %d free' % (name, row['connections'], free)})
|
|
375
|
+
if left_out:
|
|
376
|
+
problems.append({'level': 'note', 'text': '%d %s the cluster lists %s not been heard of for over an hour: taken as retired and not shown'
|
|
377
|
+
% (left_out, 'router' if left_out == 1 else 'routers', 'has' if left_out == 1 else 'have')})
|
|
378
|
+
if unlocated:
|
|
379
|
+
problems.append({'level': 'warning', 'text': 'Router identities could not be located: %s. They may be the unnamed servers that do not answer; not counted twice.' % ', '.join(unlocated)})
|
|
380
|
+
state = 'degraded' if any(problem['level'] == 'error' for problem in problems) else 'ok'
|
|
381
|
+
user = [row for row in found['databases'] if row['name'] not in ('admin', 'local', 'config')]
|
|
382
|
+
summary = dict(counts, set=found.get('set'), members=len(found['members']), shards=len(found['shards']), databases=len(user),
|
|
383
|
+
size=sum(row['size'] or 0 for row in found['databases']), collections=sum(row['collections'] or 0 for row in user),
|
|
384
|
+
version=', '.join(counts['versions']) or None)
|
|
385
|
+
return {'configured': True, 'state': state, 'kind': found['kind'], 'summary': summary, 'members': found['members'], 'shards': found['shards'], 'servers': servers,
|
|
386
|
+
'databases': found['databases'], 'problems': problems, 'limited': bool(found.get('limited')), 'seconds': round(time.monotonic() - started, 3)}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def health():
|
|
390
|
+
found = view()
|
|
391
|
+
if not found['configured']:
|
|
392
|
+
return None
|
|
393
|
+
summary = found['summary']
|
|
394
|
+
worst = next((problem['text'] for problem in found['problems'] if problem['level'] in ('error', 'warning')), '')
|
|
395
|
+
state = found['state'] if found['state'] != 'ok' or not any(problem['level'] == 'warning' for problem in found['problems']) else 'warning'
|
|
396
|
+
text = ('does not answer' if found['state'] == 'down' else
|
|
397
|
+
'%d of %d servers answer' % (summary['answering'], summary['servers']) if summary['answering'] < summary['servers'] else
|
|
398
|
+
'%d members' % summary['members'] if found['kind'] == 'replica set' else '%d shards' % summary['shards'] if found['kind'] == 'sharded' else 'one server')
|
|
399
|
+
return {'name': 'MongoDB', 'state': state, 'text': text + (' · ' + worst if worst and found['state'] != 'down' else '')}
|
|
@@ -3,10 +3,18 @@ a master may send from it (restart, stop).
|
|
|
3
3
|
|
|
4
4
|
A routine is one line however many processes it has (a routine with several workers is one
|
|
5
5
|
card). Its numbers are those of all its processes together. Platform processes (the Worker, the
|
|
6
|
-
Scheduler, this server) are
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
Scheduler, this server) are restarted and stopped like the others, with one exception: the
|
|
7
|
+
Scheduler is not commanded from here, because its Worker starts it again the moment it ends — a
|
|
8
|
+
stop of it would only be a delayed restart. Processes that are not a script the Worker runs (an
|
|
9
|
+
installed program such as vllm or rtxclaw) are not commanded either: a restart would try to
|
|
10
|
+
start that file as a Python script.
|
|
11
|
+
|
|
12
|
+
A restart is the Worker's own restart job: it ends the process and starts it again (the
|
|
13
|
+
Worker's own row restarts with the reset job). A stop is its kill job: the Worker ends the
|
|
14
|
+
process and does not start it again itself. What else may start it again (the Scheduler, for a
|
|
15
|
+
realtime routine of a schedule kept here) and what goes with it (this page, the API every host
|
|
16
|
+
uses, every process of the Worker's service) is what the page's confirmation says, row by row:
|
|
17
|
+
a stop is never silently undone.
|
|
10
18
|
"""
|
|
11
19
|
import hashlib
|
|
12
20
|
import os
|
|
@@ -23,6 +31,10 @@ PIDS_SHOWN = 20 # process ids listed on a card
|
|
|
23
31
|
SAMPLE_FRESH = 30 # seconds a CPU reading can serve as the start of the next one
|
|
24
32
|
SAMPLE_WAIT = 0.25 # seconds the first reading of a process is taken over
|
|
25
33
|
BAD_STATES = ('zombie', 'dead', 'stopped', 'stop')
|
|
34
|
+
WORKER = 'SharedData.Routines.Worker' # the Worker program itself, when it is among the routines
|
|
35
|
+
WEB = 'SharedData.Web.server' # the server that serves this page, when it runs as a routine
|
|
36
|
+
API = 'SharedData.API.ServerGunicorn' # the SharedData API of this server, which every host and routine of it uses
|
|
37
|
+
SUPERVISED_KEPT = 10 # seconds the schedule's realtime scripts are kept between readings
|
|
26
38
|
# What the page asks for -> the worker's job (its own 'stop' does nothing).
|
|
27
39
|
JOBS = {job: sched.JOBS[job] for job in ('restart', 'stop')}
|
|
28
40
|
|
|
@@ -92,6 +104,49 @@ def _routines():
|
|
|
92
104
|
return found
|
|
93
105
|
|
|
94
106
|
|
|
107
|
+
def _worker_pid():
|
|
108
|
+
"""The Worker that owns this WebUI, not another Worker's process on the same host.
|
|
109
|
+
A reset is addressed to local_worker(), so it must never be offered on another row."""
|
|
110
|
+
try:
|
|
111
|
+
for parent in psutil.Process().parents():
|
|
112
|
+
words = parent.cmdline()
|
|
113
|
+
if any(words[i:i + 2] == ['-m', WORKER] for i in range(len(words) - 1)):
|
|
114
|
+
return parent.pid
|
|
115
|
+
except psutil.Error:
|
|
116
|
+
pass
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_supervised = (0.0, frozenset()) # (when, scripts the Scheduler keeps running here)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _supervised_scripts():
|
|
124
|
+
"""The scripts the Scheduler of this server keeps running: the realtime routines of the
|
|
125
|
+
schedules kept here, for this host. A killed one is started again once it has been silent,
|
|
126
|
+
so a stop of it is not durable. The set is kept a few seconds: the page asks every few."""
|
|
127
|
+
global _supervised
|
|
128
|
+
when, found = _supervised
|
|
129
|
+
if time.time() - when < SUPERVISED_KEPT:
|
|
130
|
+
return found
|
|
131
|
+
from SharedData.Web import workerpool
|
|
132
|
+
host = str(workerpool.local_worker() or '').upper()
|
|
133
|
+
scripts = set()
|
|
134
|
+
try:
|
|
135
|
+
from SharedData.Routines import ScheduleStore
|
|
136
|
+
day = datetime.now().strftime('%Y%m%d')
|
|
137
|
+
for name in ScheduleStore.names():
|
|
138
|
+
for record in ScheduleStore.state(name, day).to_dict('records'):
|
|
139
|
+
if record.get('isrealtime') and str(record.get('computer') or '').upper() == host:
|
|
140
|
+
script = str(record.get('script') or '')
|
|
141
|
+
if script:
|
|
142
|
+
scripts.add(script)
|
|
143
|
+
except Exception: # unknown is not evidence that a stop will hold
|
|
144
|
+
_supervised = (time.time(), None)
|
|
145
|
+
return None
|
|
146
|
+
_supervised = (time.time(), frozenset(scripts))
|
|
147
|
+
return _supervised[1]
|
|
148
|
+
|
|
149
|
+
|
|
95
150
|
def rows():
|
|
96
151
|
"""The routines running here, one dict each. '_args' and '_branch' are the real values and
|
|
97
152
|
never leave this module (an argument can be a secret)."""
|
|
@@ -127,15 +182,23 @@ def rows():
|
|
|
127
182
|
row['states'].add(item['state'])
|
|
128
183
|
row['started'] = item['started'] if row['started'] is None else min(row['started'], item['started'])
|
|
129
184
|
out = []
|
|
185
|
+
supervised = _supervised_scripts()
|
|
186
|
+
worker_pid = _worker_pid()
|
|
130
187
|
for row in grouped.values():
|
|
131
188
|
states = sorted(row.pop('states'))
|
|
132
189
|
row['states'] = states
|
|
133
190
|
bad = [state for state in states if state in BAD_STATES]
|
|
134
191
|
script_run = row.pop('script_run')
|
|
135
192
|
row['kind'] = 'platform' if row['platform'] else 'routine' if script_run else 'external'
|
|
193
|
+
row['role'] = ('' if row['kind'] != 'platform' else 'scheduler' if is_scheduler(row['script'])
|
|
194
|
+
else 'worker' if row['script'] == WORKER else 'web' if row['script'] == WEB else 'api' if row['script'] == API else '')
|
|
195
|
+
row['_own_worker'] = row['processes'] == 1 and worker_pid in row['pids']
|
|
196
|
+
row['supervised'] = (None if supervised is None else row['script'] in supervised) if row['kind'] == 'platform' else False
|
|
136
197
|
row.update(state=(bad or states or ['unknown'])[0], level='error' if bad else 'running',
|
|
137
198
|
cpu=round(row['cpu'], 1), memory_mb=int(row['memory_mb']), pids=sorted(row['pids'])[:PIDS_SHOWN],
|
|
138
|
-
started=_iso(row['started']),
|
|
199
|
+
started=_iso(row['started']),
|
|
200
|
+
commandable=row['kind'] == 'routine' or (row['kind'] == 'platform' and row['role'] != 'scheduler'
|
|
201
|
+
and (row['role'] != 'worker' or row['_own_worker'])))
|
|
139
202
|
out.append(row)
|
|
140
203
|
return sorted(out, key=lambda row: (row['script'].lower(), row['key']))
|
|
141
204
|
|
|
@@ -152,7 +215,13 @@ def view():
|
|
|
152
215
|
|
|
153
216
|
def command(key, job):
|
|
154
217
|
"""The worker command for *job* on the routine whose card has this key. LookupError when it
|
|
155
|
-
no longer runs here, PermissionError for
|
|
218
|
+
no longer runs here, PermissionError for the Scheduler and for an installed program,
|
|
219
|
+
ValueError for a wrong job.
|
|
220
|
+
|
|
221
|
+
A restart is the Worker's restart job (it ends the process and starts it again); the Worker's
|
|
222
|
+
own row restarts with reset instead, because a restart job would end the Worker itself before
|
|
223
|
+
the thread that starts it again could run. A stop is the Worker's kill job: the Worker ends
|
|
224
|
+
the process and does not start it again itself; what else may start it, the page says."""
|
|
156
225
|
from SharedData.Web import workerpool
|
|
157
226
|
if not isinstance(job, str) or job not in JOBS:
|
|
158
227
|
raise ValueError('job must be one of: ' + ', '.join(JOBS))
|
|
@@ -160,9 +229,17 @@ def command(key, job):
|
|
|
160
229
|
if not found:
|
|
161
230
|
raise LookupError('that routine no longer runs on this server')
|
|
162
231
|
row = found[0]
|
|
163
|
-
if row['kind']
|
|
164
|
-
raise PermissionError('this process is not a
|
|
165
|
-
|
|
232
|
+
if row['kind'] == 'external':
|
|
233
|
+
raise PermissionError('this process is an installed program, not a script the Worker runs: it is not restarted or stopped from here')
|
|
234
|
+
if is_scheduler(row['script']):
|
|
235
|
+
raise PermissionError('the Scheduler is not restarted or stopped from here: its Worker keeps it running '
|
|
236
|
+
'(end its process on that host and the Worker starts it again)')
|
|
237
|
+
target = workerpool.local_worker()
|
|
238
|
+
if row['role'] == 'worker' and not row['_own_worker']:
|
|
239
|
+
raise PermissionError('this is not the single Worker process that owns this WebUI; it cannot be commanded from here')
|
|
240
|
+
if row['role'] == 'worker' and job == 'restart':
|
|
241
|
+
return {'sender': 'MASTER', 'job': 'reset', 'target': target, 'reset_nginx': False} # only the Worker and its children
|
|
242
|
+
command = {'sender': 'MASTER', 'job': JOBS[job], 'target': target, 'repo': row['repo'], 'routine': row['routine']}
|
|
166
243
|
if row['branch'] and row['branch'] != 'nan':
|
|
167
244
|
command['branch'] = row['branch']
|
|
168
245
|
if row['_args'] and str(row['_args']) != 'nan':
|