diff --git a/ApplicationLibCode/Application/RiaApplication.cpp b/ApplicationLibCode/Application/RiaApplication.cpp index 5d41856e2a..5aeaa342c0 100644 --- a/ApplicationLibCode/Application/RiaApplication.cpp +++ b/ApplicationLibCode/Application/RiaApplication.cpp @@ -17,6 +17,7 @@ ///////////////////////////////////////////////////////////////////////////////// #include "RiaApplication.h" +#include "Cloud/RiaCloudApiService.h" #include "Cloud/RiaConnectorTools.h" #include "Cloud/RiaOsduConnector.h" #include "Cloud/RiaSumoConnector.h" @@ -199,6 +200,11 @@ RiaApplication::RiaApplication() //-------------------------------------------------------------------------------------------------- RiaApplication::~RiaApplication() { + if ( m_cloudApiService ) + { + m_cloudApiService->stop(); + } + RiaFontCache::clear(); // Shutdown CAF logging bridge @@ -1919,11 +1925,33 @@ RiaSumoConnector* RiaApplication::makeSumoConnector() m_sumoConnector = new RiaSumoConnector( RiuMainWindow::instance(), server, authority, scopes, clientId, port ); m_sumoConnector->setTokenDataFilePath( RiaSumoDefines::tokenPath() ); m_sumoConnector->importTokenFromFile(); + + // Start the local ri_cloud_api service once Sumo authentication has been performed. + QObject::connect( m_sumoConnector, + &RiaSumoConnector::tokenReady, + cloudApiService(), + [this]( const QString& ) { cloudApiService()->start(); } ); } return m_sumoConnector; } +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiaCloudApiService* RiaApplication::cloudApiService() +{ + if ( !m_cloudApiService ) + { + m_cloudApiService = std::make_unique(); + + // Ensure the service process is killed while the event loop is still alive at shutdown. + QObject::connect( QCoreApplication::instance(), &QCoreApplication::aboutToQuit, m_cloudApiService.get(), &RiaCloudApiService::stop ); + } + + return m_cloudApiService.get(); +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- diff --git a/ApplicationLibCode/Application/RiaApplication.h b/ApplicationLibCode/Application/RiaApplication.h index 25178d6df1..80c0e60898 100644 --- a/ApplicationLibCode/Application/RiaApplication.h +++ b/ApplicationLibCode/Application/RiaApplication.h @@ -67,6 +67,7 @@ class RiuMainWindowBase; class RiuPlotMainWindow; class RiuRecentFileActionProvider; class RiaArgumentParser; +class RiaCloudApiService; class RiaOsduConnector; class RiaSumoConnector; @@ -202,6 +203,8 @@ public: RiaOsduConnector* makeOsduConnector(); RiaSumoConnector* makeSumoConnector(); + RiaCloudApiService* cloudApiService(); + RiaKeyValueStore* keyValueStore() const; protected: @@ -270,4 +273,6 @@ private: static RiaApplication* s_riaApplication; QPointer m_osduConnector; QPointer m_sumoConnector; + + std::unique_ptr m_cloudApiService; }; diff --git a/ApplicationLibCode/Application/Tools/Cloud/CMakeLists_files.cmake b/ApplicationLibCode/Application/Tools/Cloud/CMakeLists_files.cmake index 491417ebf0..4c05f2d8e6 100644 --- a/ApplicationLibCode/Application/Tools/Cloud/CMakeLists_files.cmake +++ b/ApplicationLibCode/Application/Tools/Cloud/CMakeLists_files.cmake @@ -1,4 +1,5 @@ set(SOURCE_GROUP_SOURCE_FILES + ${CMAKE_CURRENT_LIST_DIR}/RiaCloudApiService.cpp ${CMAKE_CURRENT_LIST_DIR}/RiaCloudConnector.cpp ${CMAKE_CURRENT_LIST_DIR}/RiaSumoConnector.cpp ${CMAKE_CURRENT_LIST_DIR}/RiaSumoDefines.cpp diff --git a/ApplicationLibCode/Application/Tools/Cloud/RiaCloudApiService.cpp b/ApplicationLibCode/Application/Tools/Cloud/RiaCloudApiService.cpp new file mode 100644 index 0000000000..8ae33f2151 --- /dev/null +++ b/ApplicationLibCode/Application/Tools/Cloud/RiaCloudApiService.cpp @@ -0,0 +1,359 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#include "RiaCloudApiService.h" + +#include "RiaApplication.h" +#include "RiaLogging.h" +#include "RiaQStringFormatter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace +{ +// Grace period after starting the process before the first health check, to allow for the +// initial uvicorn boot and module imports. +constexpr int startupGraceMs = 10 * 1000; + +// Poll the /alive endpoint at this interval. +constexpr int healthCheckIntervalMs = 10 * 1000; + +// Restart the service after this many consecutive failed health checks. +constexpr int maxConsecutiveFailures = 2; + +// Per-request timeout for the health check, kept well below the poll interval. +constexpr int healthCheckTimeoutMs = 5 * 1000; + +// Range of ports scanned when looking for a free port to bind the service to. +constexpr int firstPortNumber = 8000; +constexpr int portRangeLength = 100; +} // namespace + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiaCloudApiService::RiaCloudApiService( QObject* parent ) + : QObject( parent ) + , m_process( nullptr ) + , m_networkAccessManager( new QNetworkAccessManager( this ) ) + , m_port( -1 ) + , m_consecutiveFailures( 0 ) +{ + m_healthTimer.setInterval( healthCheckIntervalMs ); + connect( &m_healthTimer, &QTimer::timeout, this, &RiaCloudApiService::onHealthCheck ); + + // After the startup grace period has elapsed, begin periodic health checks. + m_startupTimer.setSingleShot( true ); + m_startupTimer.setInterval( startupGraceMs ); + connect( &m_startupTimer, &QTimer::timeout, this, [this]() { m_healthTimer.start(); } ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +RiaCloudApiService::~RiaCloudApiService() +{ + stop(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RiaCloudApiService::isRunning() const +{ + return m_process && m_process->state() != QProcess::NotRunning; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +int RiaCloudApiService::port() const +{ + return m_port; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaCloudApiService::start() +{ + if ( isRunning() ) return; + + // Clean up any stale QProcess instance (e.g. after an unexpected exit) before re-starting. + if ( m_process ) stop(); + + const QString pythonExecutable = RiaApplication::instance()->pythonPath(); + if ( pythonExecutable.isEmpty() ) + { + RiaLogging::error( "Cloud API service: no Python executable configured, cannot start service." ); + return; + } + + const QString workingDirectory = serviceWorkingDirectory(); + if ( workingDirectory.isEmpty() ) + { + RiaLogging::error( "Cloud API service: could not locate the 'ri_cloud_api' service directory, cannot start service." ); + return; + } + + const int port = findAvailablePortNumber(); + if ( port < 0 ) + { + RiaLogging::error( "Cloud API service: no available port found, cannot start service." ); + return; + } + m_port = port; + + QStringList arguments; + arguments << "-m" + << "uvicorn" + << "ri_cloud_api.main:app" + << "--host" + << "127.0.0.1" + << "--port" << QString::number( m_port ); + + m_process = new QProcess( this ); + m_process->setWorkingDirectory( workingDirectory ); + m_process->setProcessEnvironment( buildProcessEnvironment( workingDirectory ) ); + m_process->setProcessChannelMode( QProcess::MergedChannels ); + + // Forward the server's stdout/stderr to the ResInsight log so boot failures (e.g. a missing + // 'uvicorn' or import errors in the service) are visible. + connect( m_process, &QProcess::readyReadStandardOutput, this, &RiaCloudApiService::onReadyReadStandardOutput ); + + connect( m_process, + &QProcess::errorOccurred, + this, + [this]( QProcess::ProcessError ) + { RiaLogging::error( std::format( "Cloud API service: process error: {}", m_process->errorString() ) ); } ); + + connect( m_process, + &QProcess::finished, + this, + []( int exitCode, QProcess::ExitStatus ) + { RiaLogging::info( std::format( "Cloud API service: process finished (exit code {}).", exitCode ) ); } ); + + m_consecutiveFailures = 0; + + RiaLogging::info( std::format( "Cloud API service: launching '{} {}' (working directory '{}').", + pythonExecutable, + arguments.join( ' ' ), + workingDirectory ) ); + + m_process->start( pythonExecutable, arguments ); + + RiaLogging::info( std::format( "Cloud API service: starting on http://127.0.0.1:{}.", m_port ) ); + + // Delay the first health check by the startup grace period to allow the server to boot. + m_startupTimer.start(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaCloudApiService::stop() +{ + m_startupTimer.stop(); + m_healthTimer.stop(); + + if ( m_process ) + { + // Disconnect to avoid acting on the finished() signal during a deliberate shutdown. + m_process->disconnect( this ); + if ( m_process->state() != QProcess::NotRunning ) + { + m_process->kill(); + m_process->waitForFinished( 3000 ); + } + delete m_process; + m_process = nullptr; + } + + m_port = -1; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaCloudApiService::restart() +{ + RiaLogging::warning( "Cloud API service: not responding, restarting." ); + stop(); + start(); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaCloudApiService::onReadyReadStandardOutput() +{ + const QString output = QString::fromLocal8Bit( m_process->readAllStandardOutput() ); + for ( const QString& line : output.split( '\n', Qt::SkipEmptyParts ) ) + { + RiaLogging::info( std::format( "Cloud API service: {}", line.trimmed() ) ); + } +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaCloudApiService::onHealthCheck() +{ + if ( !isRunning() || m_port < 0 ) + { + restart(); + return; + } + + QNetworkRequest request( QUrl( QString( "http://127.0.0.1:%1/alive" ).arg( m_port ) ) ); + request.setTransferTimeout( healthCheckTimeoutMs ); + + QNetworkReply* reply = m_networkAccessManager->get( request ); + connect( reply, + &QNetworkReply::finished, + this, + [this, reply]() + { + reply->deleteLater(); + + const int statusCode = reply->attribute( QNetworkRequest::HttpStatusCodeAttribute ).toInt(); + if ( reply->error() == QNetworkReply::NoError && statusCode == 200 ) + { + m_consecutiveFailures = 0; + } + else + { + m_consecutiveFailures++; + if ( m_consecutiveFailures >= maxConsecutiveFailures ) + { + restart(); + } + } + } ); +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +int RiaCloudApiService::findAvailablePortNumber() +{ + const int endPort = std::min( firstPortNumber + portRangeLength, (int)std::numeric_limits::max() ); + + QTcpServer serverTest; + for ( quint16 port = static_cast( firstPortNumber ); port <= static_cast( endPort ); ++port ) + { + if ( serverTest.listen( QHostAddress::LocalHost, port ) ) + { + serverTest.close(); + return static_cast( port ); + } + } + return -1; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QString RiaCloudApiService::serviceWorkingDirectory() +{ + // The 'ri_cloud_api' package lives under the Python examples folder. Probe a set of candidate + // locations and return the first one that actually contains the package. The configured shared + // script folder(s) are probed first, since in a development build the executable lives in the + // build tree and not next to the Python examples. + const QString appDir = QCoreApplication::applicationDirPath(); + + QStringList candidates; + + // Configured "Shared Script Folder(s)" (semicolon-separated, defaults to the Python examples folder). + const QStringList scriptDirectories = RiaApplication::instance()->scriptDirectories().split( ';', Qt::SkipEmptyParts ); + for ( const QString& scriptDir : scriptDirectories ) + { + candidates << scriptDir.trimmed(); + } + + // Locations relative to the executable, used for an installed ResInsight. + candidates << appDir + "/CloudServiceApi/"; + + for ( const QString& candidate : candidates ) + { + if ( candidate.isEmpty() ) continue; + + QDir dir( candidate ); + if ( dir.exists( "ri_cloud_api" ) ) + { + return dir.absolutePath(); + } + } + + RiaLogging::error( std::format( "Cloud API service: 'ri_cloud_api' not found in any of: {}", candidates.join( ", " ) ) ); + + return {}; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +QProcessEnvironment RiaCloudApiService::buildProcessEnvironment( const QString& workingDirectory ) +{ + // The service runs in the configured Python interpreter (typically a virtual environment), so + // uvicorn/fastapi are resolved from that environment. The 'ri_cloud_api' package additionally + // depends on local workspace libraries laid out as 'ri_cloud_api/libs//src'. Add the + // discovered 'src' folders to PYTHONPATH so the service can be imported without a separate + // install step. + // + // INTERIM: this PYTHONPATH injection is a temporary measure while the service runs from the + // source tree. Once 'ri_cloud_api' is distributed and installed as a pip package, the workspace + // libraries will resolve from site-packages and this block should be removed. See the package + // README.md for details. + QProcessEnvironment environment = RiaApplication::instance()->pythonProcessEnvironment(); + + QStringList pythonPaths; + + QDir libsDir( workingDirectory + "/ri_cloud_api/libs" ); + const QFileInfoList libEntries = libsDir.entryInfoList( QDir::Dirs | QDir::NoDotAndDotDot ); + for ( const QFileInfo& libEntry : libEntries ) + { + const QString srcPath = libEntry.absoluteFilePath() + "/src"; + if ( QDir( srcPath ).exists() ) + { + pythonPaths << QDir::toNativeSeparators( srcPath ); + } + } + + if ( !pythonPaths.isEmpty() ) + { + const QString existing = environment.value( "PYTHONPATH" ); + if ( !existing.isEmpty() ) pythonPaths << existing; + + environment.insert( "PYTHONPATH", pythonPaths.join( QDir::listSeparator() ) ); + } + + return environment; +} diff --git a/ApplicationLibCode/Application/Tools/Cloud/RiaCloudApiService.h b/ApplicationLibCode/Application/Tools/Cloud/RiaCloudApiService.h new file mode 100644 index 0000000000..dd079e8ec9 --- /dev/null +++ b/ApplicationLibCode/Application/Tools/Cloud/RiaCloudApiService.h @@ -0,0 +1,70 @@ +///////////////////////////////////////////////////////////////////////////////// +// +// Copyright (C) 2026- Equinor ASA +// +// ResInsight is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. +// +// See the GNU General Public License at +// for more details. +// +///////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include +#include + +class QProcess; +class QNetworkAccessManager; + +//================================================================================================== +/// +/// Manages the life cycle of the local "ri_cloud_api" uvicorn service. +/// +/// The service is a long-lived localhost process (unlike the single-shot script worker process in +/// RiaApplication). It is started when Sumo cloud authentication is performed, bound to an +/// automatically selected free port, polled for liveness on its /alive endpoint, restarted on a +/// new port if it stops responding, and killed when ResInsight closes. +/// +//================================================================================================== +class RiaCloudApiService : public QObject +{ + Q_OBJECT +public: + explicit RiaCloudApiService( QObject* parent = nullptr ); + ~RiaCloudApiService() override; + + void start(); + void stop(); + void restart(); + + bool isRunning() const; + int port() const; + +private slots: + void onHealthCheck(); + void onReadyReadStandardOutput(); + +private: + static int findAvailablePortNumber(); + static QString serviceWorkingDirectory(); + static QProcessEnvironment buildProcessEnvironment( const QString& workingDirectory ); + +private: + QPointer m_process; + QNetworkAccessManager* m_networkAccessManager; + QTimer m_startupTimer; + QTimer m_healthTimer; + + int m_port; + int m_consecutiveFailures; +}; diff --git a/ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.cpp b/ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.cpp index 387dcb9a1c..987ff2f599 100644 --- a/ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.cpp +++ b/ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.cpp @@ -18,6 +18,7 @@ #include "RimCloudDataSourceCollection.h" +#include "Cloud/RiaCloudApiService.h" #include "RiaApplication.h" #include "Summary/RiaSummaryPlotTools.h" #include "Summary/RiaSummaryTools.h" @@ -59,6 +60,15 @@ RimCloudDataSourceCollection::RimCloudDataSourceCollection() CAF_PDM_InitFieldNoDefault( &m_sumoDataSources, "SumoDataSources", "Sumo Data Sources" ); + CAF_PDM_InitFieldNoDefault( &m_startServer, "StartCloudApiServer", "" ); + caf::PdmUiPushButtonEditor::configureEditorLabelLeft( &m_startServer ); + + CAF_PDM_InitFieldNoDefault( &m_stopServer, "StopCloudApiServer", "" ); + caf::PdmUiPushButtonEditor::configureEditorLabelLeft( &m_stopServer ); + + CAF_PDM_InitFieldNoDefault( &m_restartServer, "RestartCloudApiServer", "" ); + caf::PdmUiPushButtonEditor::configureEditorLabelLeft( &m_restartServer ); + m_sumoConnector = RiaApplication::instance()->makeSumoConnector(); } @@ -106,6 +116,33 @@ void RimCloudDataSourceCollection::createEnsemblesFromSelectedDataSources( const //-------------------------------------------------------------------------------------------------- void RimCloudDataSourceCollection::fieldChangedByUi( const caf::PdmFieldHandle* changedField, const QVariant& oldValue, const QVariant& newValue ) { + // The Cloud API server controls are independent of the Sumo connection, so handle them before the + // Sumo connector guard below. This allows force-starting the server even when authentication failed. + if ( changedField == &m_startServer ) + { + RiaApplication::instance()->cloudApiService()->start(); + + m_startServer = false; + updateConnectedEditors(); + return; + } + else if ( changedField == &m_stopServer ) + { + RiaApplication::instance()->cloudApiService()->stop(); + + m_stopServer = false; + updateConnectedEditors(); + return; + } + else if ( changedField == &m_restartServer ) + { + RiaApplication::instance()->cloudApiService()->restart(); + + m_restartServer = false; + updateConnectedEditors(); + return; + } + if ( !m_sumoConnector ) return; if ( changedField == &m_authenticate ) @@ -198,6 +235,7 @@ QList RimCloudDataSourceCollection::calculateValueOption //-------------------------------------------------------------------------------------------------- void RimCloudDataSourceCollection::defineUiOrdering( QString uiConfigName, caf::PdmUiOrdering& uiOrdering ) { + // Authentication group auto authGroup = uiOrdering.addNewGroup( "Authentication" ); authGroup->add( &m_authenticate ); @@ -208,6 +246,32 @@ void RimCloudDataSourceCollection::defineUiOrdering( QString uiConfigName, caf:: m_authenticate.uiCapability()->setUiName( text ); m_authenticate.uiCapability()->setUiReadOnly( isGranted ); + // Cloud server group + auto serverGroup = uiOrdering.addNewGroup( QString( "Cloud API Server %1" ).arg( RiaDefines::betaFeaturePostfix() ) ); + + auto* cloudApiService = RiaApplication::instance()->cloudApiService(); + bool isServerRunning = cloudApiService && cloudApiService->isRunning(); + QString serverStatus = "Server Status: "; + if ( isServerRunning ) + { + serverStatus += QString( "✔ Running (port %1)" ).arg( cloudApiService->port() ); + } + else + { + serverStatus += "❌ Stopped"; + } + + // The status text is shown as the left label of the Start button, mirroring the Authentication status above. + m_startServer.uiCapability()->setUiName( serverStatus ); + m_startServer.uiCapability()->setUiReadOnly( isServerRunning ); + m_stopServer.uiCapability()->setUiReadOnly( !isServerRunning ); + m_restartServer.uiCapability()->setUiReadOnly( !isServerRunning ); + + serverGroup->add( &m_startServer ); + serverGroup->add( &m_stopServer ); + serverGroup->add( &m_restartServer ); + + // Cloud selector if ( isGranted ) { caf::PdmUiOrdering::LayoutOptions layout = { .newRow = true, .totalColumnSpan = 3, .leftLabelColumnSpan = 1 }; @@ -218,6 +282,7 @@ void RimCloudDataSourceCollection::defineUiOrdering( QString uiConfigName, caf:: uiOrdering.add( &m_addDataSources, layout ); uiOrdering.add( &m_addEnsembles, layout ); } + uiOrdering.skipRemainingFields(); } @@ -249,6 +314,27 @@ void RimCloudDataSourceCollection::defineEditorAttribute( const caf::PdmFieldHan attrib->m_buttonText = "Add Ensemble(s)"; } } + else if ( field == &m_startServer ) + { + if ( auto attrib = dynamic_cast( attribute ) ) + { + attrib->m_buttonText = "Start"; + } + } + else if ( field == &m_stopServer ) + { + if ( auto attrib = dynamic_cast( attribute ) ) + { + attrib->m_buttonText = "Stop"; + } + } + else if ( field == &m_restartServer ) + { + if ( auto attrib = dynamic_cast( attribute ) ) + { + attrib->m_buttonText = "Restart"; + } + } } //-------------------------------------------------------------------------------------------------- diff --git a/ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.h b/ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.h index a311c3c71c..bbb1090d36 100644 --- a/ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.h +++ b/ApplicationLibCode/ProjectDataModel/Cloud/RimCloudDataSourceCollection.h @@ -66,5 +66,9 @@ private: caf::PdmField m_addEnsembles; caf::PdmChildArrayField m_sumoDataSources; + caf::PdmField m_startServer; + caf::PdmField m_stopServer; + caf::PdmField m_restartServer; + QPointer m_sumoConnector; }; diff --git a/CMakeLists.txt b/CMakeLists.txt index 464c4c48f5..b94890a104 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1330,3 +1330,14 @@ if(RESINSIGHT_BUILD_DOCUMENTATION) # IF you do NOT want the documentation to be generated EVERY time you build # the project then leave out the 'ALL' keyword from the above command. endif(RESINSIGHT_BUILD_DOCUMENTATION) + +# ############################################################################## +# Installation packaging Cloud Service API +# ############################################################################## +set(CLOUD_SERVICE_API_PYTHON_SOURCE_PATH + "${CMAKE_CURRENT_SOURCE_DIR}/scripts/ri_cloud_api" +) + +install(DIRECTORY ${CLOUD_SERVICE_API_PYTHON_SOURCE_PATH}/ + DESTINATION ${RESINSIGHT_INSTALL_FOLDER}/CloudServiceApi/ri_cloud_api +) diff --git a/scripts/ri_cloud_api/README.md b/scripts/ri_cloud_api/README.md new file mode 100644 index 0000000000..0bc88d5d0c --- /dev/null +++ b/scripts/ri_cloud_api/README.md @@ -0,0 +1,74 @@ +# ResInsight Cloud API service + +A localhost FastAPI/uvicorn service whose lifecycle is managed by ResInsight +(`RiaCloudApiService`): it is started when Sumo authentication succeeds, bound to an +auto-selected free port, health-checked on `/alive`, restarted if unresponsive, and +killed when ResInsight closes. + +## Location + +The package lives at `scripts/ri_cloud_api` in the repository root. + +On install, the contents of this folder are copied to `/CloudServiceApi` +(see the *Installation packaging Cloud Service API* section in the top-level +`CMakeLists.txt`). + +`RiaCloudApiService::serviceWorkingDirectory` locates the package by probing, in order: + +1. the configured **Shared Script Folder(s)** + (**Preferences → Scripting → Shared Script Folder(s)**), and +2. `/CloudServiceApi/` + +and returning the first folder that contains a `ri_cloud_api` directory. For a +development build, point one of the shared script folders at the repository `scripts` +folder (e.g. `C:/Git/ResInsight/scripts`), since the executable lives in the build tree +and not next to the package. + +## Deployment status + +> **Target state:** this service is intended to be distributed and installed as a +> regular **pip package** into the active Python environment. Once packaged, importing +> `ri_cloud_api` (and any `ri_cloud_*` workspace libraries) will work directly from the +> environment's site-packages, with no path manipulation. +> +> **Current state (interim):** the service is **not yet a pip package**. It runs from the +> source tree inside a virtual environment, with the folder containing `ri_cloud_api` used +> as the process working directory. If workspace libraries are laid out as +> `ri_cloud_api/libs//src`, ResInsight adds each such `src` folder to `PYTHONPATH` at +> launch time (see `RiaCloudApiService::buildProcessEnvironment`). This is a temporary +> measure that should be removed once the package is published/installed. + +## Requirements (interim setup) + +- A Python virtual environment configured in ResInsight under + **Preferences → Scripting → Python Executable** + (e.g. `C:/Git/.venvResInsight/Scripts/python.exe`). +- `uvicorn` and `fastapi` installed in that environment, plus any third-party + dependencies pulled in by the routers. + +## Contents + + ri_cloud_api/ + main.py shim exposing `app` for `uvicorn ri_cloud_api.main:app` + primary/ + main.py FastAPI application, router registration + routers/health/router.py `/alive` health endpoint + +## Running manually (for debugging) + +From the `scripts` folder (the parent of this package): + + cd scripts + /Scripts/python.exe -m uvicorn ri_cloud_api.main:app --host 127.0.0.1 --port 8000 + +If workspace libraries are present, put them on `PYTHONPATH` first: + + set PYTHONPATH=ri_cloud_api/libs//src + +Then check the health endpoint: + + http://127.0.0.1:8000/alive -> {"status": "alive"} + +Interactive API docs are available at: + + http://127.0.0.1:8000/docs diff --git a/scripts/ri_cloud_api/__init__.py b/scripts/ri_cloud_api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/ri_cloud_api/main.py b/scripts/ri_cloud_api/main.py new file mode 100644 index 0000000000..0f0ff66986 --- /dev/null +++ b/scripts/ri_cloud_api/main.py @@ -0,0 +1,11 @@ +"""ResInsight Cloud API entrypoint. + +Thin shim so ``uvicorn ri_cloud_api.main:app`` keeps working alongside the +real application module at :mod:`ri_cloud_api.primary.main`. +""" + +from __future__ import annotations + +from .primary.main import app + +__all__ = ["app"] diff --git a/scripts/ri_cloud_api/primary/__init__.py b/scripts/ri_cloud_api/primary/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/ri_cloud_api/primary/main.py b/scripts/ri_cloud_api/primary/main.py new file mode 100644 index 0000000000..b62cae0962 --- /dev/null +++ b/scripts/ri_cloud_api/primary/main.py @@ -0,0 +1,43 @@ +"""ResInsight Cloud API — FastAPI application. + +Lifecycle is managed by ResInsight (RiaCloudApiService): the service is started when Sumo +authentication succeeds, bound to an auto-selected free port, health-checked on /alive, +restarted if unresponsive, and killed when ResInsight closes. + +Deployment status: + Target state — distributed and installed as a regular pip package, importable directly + from the environment's site-packages. + Current state (interim) — runs from the source tree in a virtual environment; ResInsight + injects each ri_cloud_api/libs//src folder onto PYTHONPATH at launch time. This is + temporary and should be removed once the package is published/installed. + +See README.md (next to this package) for details. + +Setup: + pip install poetry + python -m venv .venv + source .venv/bin/activate + poetry install + +Run (from the repository root): + uvicorn ri_cloud_api.primary.main:app --host 0.0.0.0 --port 8000 --reload + +Docs: + http://localhost:8000/docs +""" + +from __future__ import annotations + +import logging + +from fastapi import FastAPI + +from .routers.health.router import router as health_router + +logger = logging.getLogger("ri_cloud_api") +logging.basicConfig(level=logging.INFO) + + +app = FastAPI(title="ResInsight Cloud API") + +app.include_router(health_router) \ No newline at end of file diff --git a/scripts/ri_cloud_api/primary/routers/__init__.py b/scripts/ri_cloud_api/primary/routers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/ri_cloud_api/primary/routers/health/__init__.py b/scripts/ri_cloud_api/primary/routers/health/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/ri_cloud_api/primary/routers/health/router.py b/scripts/ri_cloud_api/primary/routers/health/router.py new file mode 100644 index 0000000000..87e602cb0a --- /dev/null +++ b/scripts/ri_cloud_api/primary/routers/health/router.py @@ -0,0 +1,12 @@ + + + +from fastapi import APIRouter + + +router = APIRouter(tags=["health"]) + +@router.get("/alive") +def alive() -> dict[str, str]: + """Health-check endpoint polled by ResInsight for service life cycle management.""" + return {"status": "alive"} \ No newline at end of file