mirror of
https://github.com/OPM/ResInsight.git
synced 2026-10-26 15:10:22 -05:00
Initial ri-cloud-api lifecycle management test (#14364)
Co-authored-by: Magne Sjaastad <magne.sjaastad@ceetronsolutions.com> Co-authored-by: magnesj <1793152+magnesj@users.noreply.github.com>
This commit is contained in:
16 files changed
+704
No files matched your search
@@ -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<RiaCloudApiService>();
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------------------------------
|
||||
///
|
||||
//--------------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -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<char>* keyValueStore() const;
|
||||
|
||||
protected:
|
||||
@@ -270,4 +273,6 @@ private:
|
||||
static RiaApplication* s_riaApplication;
|
||||
QPointer<RiaOsduConnector> m_osduConnector;
|
||||
QPointer<RiaSumoConnector> m_sumoConnector;
|
||||
|
||||
std::unique_ptr<RiaCloudApiService> m_cloudApiService;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/gpl.html>
|
||||
// for more details.
|
||||
//
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
#include "RiaCloudApiService.h"
|
||||
|
||||
#include "RiaApplication.h"
|
||||
#include "RiaLogging.h"
|
||||
#include "RiaQStringFormatter.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QHostAddress>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QProcess>
|
||||
#include <QTcpServer>
|
||||
#include <QUrl>
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
|
||||
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<quint16>::max() );
|
||||
|
||||
QTcpServer serverTest;
|
||||
for ( quint16 port = static_cast<quint16>( firstPortNumber ); port <= static_cast<quint16>( endPort ); ++port )
|
||||
{
|
||||
if ( serverTest.listen( QHostAddress::LocalHost, port ) )
|
||||
{
|
||||
serverTest.close();
|
||||
return static_cast<int>( 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/<lib>/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;
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/gpl.html>
|
||||
// for more details.
|
||||
//
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QTimer>
|
||||
|
||||
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<QProcess> m_process;
|
||||
QNetworkAccessManager* m_networkAccessManager;
|
||||
QTimer m_startupTimer;
|
||||
QTimer m_healthTimer;
|
||||
|
||||
int m_port;
|
||||
int m_consecutiveFailures;
|
||||
};
|
||||
Reference in new issue
Block a user