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:
authored and GitHub committed 2026-07-28 09:36:14 +02:00
1 parent 0a3ea68dea
commit c3e7c05fd6
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;
};