shareddata 6.85.12__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.12/src/shareddata.egg-info → shareddata-6.85.15}/PKG-INFO +1 -1
- {shareddata-6.85.12 → shareddata-6.85.15}/setup.py +1 -1
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Routines/JobStore.py +2 -2
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Routines/WorkerLib.py +40 -3
- shareddata-6.85.15/src/SharedData/Web/mongo_view.py +399 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/proc.py +86 -9
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/standalone.py +2 -1
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/static/standalone.css +18 -3
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/static/standalone.js +120 -24
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/templates/standalone.html +3 -2
- {shareddata-6.85.12 → shareddata-6.85.15/src/shareddata.egg-info}/PKG-INFO +1 -1
- shareddata-6.85.12/src/SharedData/Web/mongo_view.py +0 -125
- {shareddata-6.85.12 → shareddata-6.85.15}/LICENSE +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/MANIFEST.in +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/README.md +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/pyproject.toml +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/setup.cfg +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/ServerGunicorn.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/ServerWaitress.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/__init__.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/apidocs-dark.css +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/apidocs.yml +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/auth.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/constants.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/download_paths.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/download_slots.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/install/worker.ps1 +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/install/worker.sh +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/listcache.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/routes/__init__.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/routes/cache.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/routes/collections.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/routes/data.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/routes/downloads.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/routes/metadata.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/routes/operations.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/routes/schedules.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/routes/sqlite.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/routes/system.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/routes/tables.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/routes/timeseries.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/routes/web_routes.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/routes/workers.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/API/utils.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Auth/__init__.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Auth/bootstrap.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Auth/migrate.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Auth/store.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Auth/workers.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/CacheRedis.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/CollectionMongoDB.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/DataAPI/__init__.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/DataAPI/catalog.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/DataAPI/config.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/DataAPI/errors.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/DataAPI/indexes.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/DataAPI/readers.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/DataAPI/resolve.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/DataAPI/service.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Database.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Defaults.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/IO/AWSEC2.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/IO/AWSS3.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/IO/AutoDocstrings.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/IO/ClientAPI.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/IO/ClientSocket.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/IO/ClientWebSocket.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/IO/LogHandlerAPI.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/IO/LogReader.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/IO/MongoDBClient.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/IO/SaveTables.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/IO/ServerSocket.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/IO/ServerWebSocket.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/IO/StreamsCache.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/IO/StreamsPersist.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/IO/SyncTable.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/IO/TunnelWebSocket.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/IO/__init__.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Listing.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/LockWaiters.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Logger.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/MCP/__init__.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/MCP/__main__.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/MCP/client.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/MCP/server.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Metadata.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/MultiProc.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/OpenFIGI.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Redaction.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Routines/ArchiveOldCollections.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Routines/BatchJob.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Routines/ContainerHealth.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Routines/RoutineCommand.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Routines/RoutineOrigin.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Routines/Schedule.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Routines/ScheduleMirror.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Routines/ScheduleMonitor.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Routines/ScheduleStore.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Routines/Scheduler.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Routines/UpdateActiveJobs.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Routines/Worker.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Routines/WorkerBootstrap.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Routines/WorkerPool.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Routines/__init__.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/SQLite.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/SharedData.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/SharedNumpy.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/StreamKafka.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Symbol.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Table.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/TableDisk.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/TableIndex.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/TableIndexJitFunctions.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/TableIndexJitFunctionsManual.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/TableIndexJitGenerate.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/TableIndexJitHash.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/TableIndexJitLoc.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/TableProtocol.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/TableRead.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/TableRewrite.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/TimeSeriesDisk.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/TimeseriesContainer.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Users.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Utils.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/ADDING_A_PAGE.md +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/__init__.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/activity_logger.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/app.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/assets/dashAgGridComponentFunctions.js +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/assets/dashAgGridFunctions.js +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/assets/google_logo.png +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/assets/kafka_keys.js +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/assets/logs.css +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/assets/sched.css +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/assets/styles.css +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/auth.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/backends.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/config.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/containers.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/identity.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/kafka_view.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/redis_view.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/route_health.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/route_registry.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/sched.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/schedule_editor.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/server.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/services.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/shell.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/static/account.js +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/static/custom.css +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/static/favicon.ico +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/static/favicon.png +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/static/logo.png +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/static/logo_96x96.png +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/static/shareddata-mark.svg +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/static/shell.css +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/static/shell.js +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/suggest.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/templates/account.html +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/templates/base.html +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/templates/claim.html +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/templates/claim_done.html +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/templates/dash.html +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/templates/index.html +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/templates/login.html +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/templates/login_2fa.html +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/templates/partials/navbar.html +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/templates/partials/scripts.html +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/templates/recovery_codes.html +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/templates/shell.html +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/twofactor.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/utils.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/Web/workerpool.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/__init__.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/build_info.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/fleet.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/preflight.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/private_files.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/release_backup.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/server.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/services.py +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/SharedData/sharedmutexwin.pyd +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/shareddata.egg-info/SOURCES.txt +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/shareddata.egg-info/dependency_links.txt +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/shareddata.egg-info/entry_points.txt +0 -0
- {shareddata-6.85.12 → shareddata-6.85.15}/src/shareddata.egg-info/requires.txt +0 -0
- {shareddata-6.85.12 → 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
|
|
@@ -915,7 +952,7 @@ def _install_repo(command, quiet=False):
|
|
|
915
952
|
if not send_command(checkout_cmd):
|
|
916
953
|
Logger.log.error(f'Checking out branch {command["branch"]} FAILED!')
|
|
917
954
|
runroutine = False
|
|
918
|
-
|
|
955
|
+
elif not quiet:
|
|
919
956
|
Logger.log.info(f'Checked out branch {command["branch"]}')
|
|
920
957
|
else:
|
|
921
958
|
# Checkout main branch if no branch specified
|
|
@@ -923,7 +960,7 @@ def _install_repo(command, quiet=False):
|
|
|
923
960
|
if not send_command(checkout_cmd):
|
|
924
961
|
Logger.log.error(f'Checking out branch main FAILED!')
|
|
925
962
|
runroutine = False
|
|
926
|
-
|
|
963
|
+
elif not quiet:
|
|
927
964
|
Logger.log.info(f'Checked out branch main')
|
|
928
965
|
|
|
929
966
|
# pull existing repo
|
|
@@ -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 '')}
|