mirror of
https://github.com/OPM/ResInsight.git
synced 2026-10-26 23:20:29 -05:00
Co-authored-by: Magne Sjaastad <magne.sjaastad@ceetronsolutions.com> Co-authored-by: magnesj <1793152+magnesj@users.noreply.github.com>
360 lines
13 KiB
C++
360 lines
13 KiB
C++
/////////////////////////////////////////////////////////////////////////////////
|
|
//
|
|
// 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;
|
|
}
|