File indexing completed on 2024-11-17 04:55:44

0001 /*
0002  *   SPDX-FileCopyrightText: 2021 Mariam Fahmy Sobhy <mariamfahmy66@gmail.com>
0003  *
0004  *   SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
0005  */
0006 
0007 #include "RpmOstreeNotifier.h"
0008 
0009 #include <QDirIterator>
0010 #include <QFile>
0011 #include <QJsonArray>
0012 #include <QJsonDocument>
0013 #include <QJsonObject>
0014 #include <QProcess>
0015 #include <QTimer>
0016 #include <QVersionNumber>
0017 
0018 RpmOstreeNotifier::RpmOstreeNotifier(QObject *parent)
0019     : BackendNotifierModule(parent)
0020     , m_version(QString())
0021     , m_hasUpdates(false)
0022     , m_needsReboot(false)
0023 {
0024     // Refuse to run on systems not managed by rpm-ostree
0025     if (!isValid()) {
0026         qWarning() << "rpm-ostree-notifier: Not starting on a system not managed by rpm-ostree";
0027         return;
0028     }
0029 
0030     // Setup a  watcher to trigger a check for reboot when the deployments are changed
0031     // and there is thus likely an new deployment installed following an update.
0032     m_watcher = new QFileSystemWatcher(this);
0033 
0034     // We also setup a timer to avoid triggering a check immediately when a new
0035     // deployment is made available and instead wait a bit to let things settle down.
0036     m_timer = new QTimer(this);
0037     m_timer->setSingleShot(true);
0038     // Wait 10 seconds for all rpm-ostree operations to complete
0039     m_timer->setInterval(10000);
0040     connect(m_timer, &QTimer::timeout, this, &RpmOstreeNotifier::checkForPendingDeployment);
0041 
0042     // Find all ostree managed system installations available. There is usualy only one but
0043     // doing that dynamically here avoids hardcoding a specific value or doing a DBus call.
0044     QDirIterator it(QStringLiteral("/ostree/deploy/"), QDir::AllDirs | QDir::NoDotAndDotDot);
0045     while (it.hasNext()) {
0046         QString path = QStringLiteral("%1/deploy/").arg(it.next());
0047         m_watcher->addPath(path);
0048         qInfo() << "rpm-ostree-notifier: Looking for new deployments in" << path;
0049     }
0050     connect(m_watcher, &QFileSystemWatcher::directoryChanged, [this]() {
0051         m_timer->start();
0052     });
0053 
0054     qInfo() << "rpm-ostree-notifier: Looking for ostree format";
0055     m_process = new QProcess(this);
0056     m_stdout = QByteArray();
0057 
0058     // Display stderr
0059     connect(m_process, &QProcess::readyReadStandardError, [this]() {
0060         qWarning() << "rpm-ostree (error):" << m_process->readAllStandardError();
0061     });
0062 
0063     // Store stdout to process as JSON
0064     connect(m_process, &QProcess::readyReadStandardOutput, [this]() {
0065         m_stdout += m_process->readAllStandardOutput();
0066     });
0067 
0068     // Process command result
0069     connect(m_process, &QProcess::finished, [this](int exitCode, QProcess::ExitStatus exitStatus) {
0070         m_process->deleteLater();
0071         m_process = nullptr;
0072         if (exitStatus != QProcess::NormalExit) {
0073             qWarning() << "rpm-ostree-notifier: Failed to check for existing deployments";
0074             return;
0075         }
0076         if (exitCode != 0) {
0077             // Unexpected error
0078             qWarning() << "rpm-ostree-notifier: Failed to check for existing deployments. Exit code:" << exitCode;
0079             return;
0080         }
0081 
0082         // Parse stdout as JSON and look at the currently booted deployments to figure out
0083         // the format used by ostree
0084         const QJsonDocument jsonDocument = QJsonDocument::fromJson(m_stdout);
0085         if (!jsonDocument.isObject()) {
0086             qWarning() << "rpm-ostree-notifier: Could not parse 'rpm-ostree status' output as JSON";
0087             return;
0088         }
0089         const QJsonArray deployments = jsonDocument.object().value(QLatin1String("deployments")).toArray();
0090         if (deployments.isEmpty()) {
0091             qWarning() << "rpm-ostree-notifier: Could not find the deployments in 'rpm-ostree status' JSON output";
0092             return;
0093         }
0094         bool booted;
0095         for (const QJsonValue &deployment : deployments) {
0096             booted = deployment.toObject()[QLatin1String("booted")].toBool();
0097             if (!booted) {
0098                 continue;
0099             }
0100             // Look for "classic" ostree origin format first
0101             QString origin = deployment.toObject()[QLatin1String("origin")].toString();
0102             if (!origin.isEmpty()) {
0103                 m_ostreeFormat.reset(new ::OstreeFormat(::OstreeFormat::Format::Classic, origin));
0104                 if (!m_ostreeFormat->isValid()) {
0105                     // This should never happen
0106                     qWarning() << "rpm-ostree-notifier: Invalid origin for classic ostree format:" << origin;
0107                 }
0108             } else {
0109                 // Then look for OCI container format
0110                 origin = deployment.toObject()[QLatin1String("container-image-reference")].toString();
0111                 if (!origin.isEmpty()) {
0112                     m_ostreeFormat.reset(new ::OstreeFormat(::OstreeFormat::Format::OCI, origin));
0113                     if (!m_ostreeFormat->isValid()) {
0114                         // This should never happen
0115                         qWarning() << "rpm-ostree-notifier: Invalid reference for OCI container ostree format:" << origin;
0116                     }
0117                 } else {
0118                     // This should never happen
0119                     m_ostreeFormat.reset(new ::OstreeFormat(::OstreeFormat::Format::Unknown, {}));
0120                     qWarning() << "rpm-ostree-notifier: Could not find a valid remote ostree format for the booted deployment";
0121                 }
0122             }
0123             // Look for the base-version first. This is the case where we have changes layered
0124             m_version = deployment.toObject()[QLatin1String("base-version")].toString();
0125             if (m_version.isEmpty()) {
0126                 // If empty, look for the regular version (no layered changes)
0127                 m_version = deployment.toObject()[QLatin1String("version")].toString();
0128             }
0129         }
0130     });
0131 
0132     m_process->start(QStringLiteral("rpm-ostree"), {QStringLiteral("status"), QStringLiteral("--json")});
0133     m_process->waitForFinished();
0134 }
0135 
0136 bool RpmOstreeNotifier::isValid() const
0137 {
0138     return QFile::exists(QStringLiteral("/run/ostree-booted"));
0139 }
0140 
0141 void RpmOstreeNotifier::recheckSystemUpdateNeeded()
0142 {
0143     // Refuse to run on systems not managed by rpm-ostree
0144     if (!isValid()) {
0145         qWarning() << "rpm-ostree-notifier: Not starting on a system not managed by rpm-ostree";
0146         return;
0147     }
0148 
0149     qInfo() << "rpm-ostree-notifier: Checking for system update";
0150     if (m_ostreeFormat->isClassic()) {
0151         checkSystemUpdateClassic();
0152     } else if (m_ostreeFormat->isOCI()) {
0153         checkSystemUpdateOCI();
0154     }
0155 }
0156 
0157 void RpmOstreeNotifier::checkSystemUpdateClassic()
0158 {
0159     qInfo() << "rpm-ostree-notifier: Checking for system update (classic format)";
0160 
0161     m_process = new QProcess(this);
0162     m_stdout = QByteArray();
0163 
0164     // Display stderr
0165     connect(m_process, &QProcess::readyReadStandardError, [this]() {
0166         qWarning() << "rpm-ostree (error):" << m_process->readAllStandardError();
0167     });
0168 
0169     // Display and store stdout
0170     connect(m_process, &QProcess::readyReadStandardOutput, [this]() {
0171         QByteArray message = m_process->readAllStandardOutput();
0172         qInfo() << "rpm-ostree:" << message;
0173         m_stdout += message;
0174     });
0175 
0176     // Process command result
0177     connect(m_process, &QProcess::finished, [this](int exitCode, QProcess::ExitStatus exitStatus) {
0178         m_process->deleteLater();
0179         m_process = nullptr;
0180         if (exitStatus != QProcess::NormalExit) {
0181             qWarning() << "rpm-ostree-notifier: Failed to check for system update";
0182             return;
0183         }
0184         if (exitCode == 77) {
0185             // rpm-ostree will exit with status 77 when no updates are available
0186             qInfo() << "rpm-ostree-notifier: No updates available";
0187             return;
0188         }
0189         if (exitCode != 0) {
0190             qWarning() << "rpm-ostree-notifier: Failed to check for system update. Exit code:" << exitCode;
0191             return;
0192         }
0193 
0194         // We have an update available. Let's look if we already have a pending
0195         // deployment for the new version. First, look for the new version
0196         // string in rpm-ostree stdout
0197         QString newVersion, line;
0198         QString output = QString::fromUtf8(m_stdout);
0199         QTextStream stream(&output);
0200         while (stream.readLineInto(&line)) {
0201             if (line.contains(QLatin1String("Version: "))) {
0202                 newVersion = line;
0203                 break;
0204             }
0205         }
0206 
0207         // Could not find the new version in rpm-ostree output. This is unlikely
0208         // to ever happen.
0209         if (newVersion.isEmpty()) {
0210             qInfo() << "rpm-ostree-notifier: Could not find the version for the update available";
0211         }
0212 
0213         // Process the string to get just the version "number".
0214         newVersion = newVersion.trimmed();
0215         newVersion.remove(0, QStringLiteral("Version: ").length());
0216         newVersion.remove(newVersion.size() - QStringLiteral(" (XXXX-XX-XXTXX:XX:XXZ)").length(), newVersion.size() - 1);
0217         qInfo() << "rpm-ostree-notifier: Found new version:" << newVersion;
0218 
0219         // Have we already notified the user about this update?
0220         if (newVersion == m_updateVersion) {
0221             qInfo() << "rpm-ostree-notifier: New version has already been offered. Skipping.";
0222             return;
0223         }
0224         m_updateVersion = newVersion;
0225 
0226         // Look for an existing deployment with this version
0227         checkForPendingDeployment();
0228     });
0229 
0230     m_process->start(QStringLiteral("rpm-ostree"), {QStringLiteral("update"), QStringLiteral("--check")});
0231 }
0232 
0233 void RpmOstreeNotifier::checkSystemUpdateOCI()
0234 {
0235     qInfo() << "rpm-ostree-notifier: Checking for system update (OCI format)";
0236 
0237     m_process = new QProcess(this);
0238     m_stdout = QByteArray();
0239 
0240     // Display stderr
0241     connect(m_process, &QProcess::readyReadStandardError, [this]() {
0242         qWarning() << "skopeo (error):" << m_process->readAllStandardError();
0243     });
0244 
0245     // Store stdout to process as JSON
0246     connect(m_process, &QProcess::readyReadStandardOutput, [this]() {
0247         m_stdout += m_process->readAllStandardOutput();
0248     });
0249 
0250     // Process command result
0251     connect(m_process, &QProcess::finished, [this](int exitCode, QProcess::ExitStatus exitStatus) {
0252         m_process->deleteLater();
0253         m_process = nullptr;
0254         if (exitStatus != QProcess::NormalExit) {
0255             qWarning() << "rpm-ostree-notifier: Failed to check for updates via skopeo";
0256             return;
0257         }
0258         if (exitCode != 0) {
0259             // Unexpected error
0260             qWarning() << "rpm-ostree-notifier: Failed to check for updates via skopeo. Exit code:" << exitCode;
0261             return;
0262         }
0263 
0264         // Parse stdout as JSON and look at the container image labels for the version
0265         const QJsonDocument jsonDocument = QJsonDocument::fromJson(m_stdout);
0266         if (!jsonDocument.isObject()) {
0267             qWarning() << "rpm-ostree-notifier: Could not parse 'rpm-ostree status' output as JSON";
0268             return;
0269         }
0270 
0271         // Get the version stored in .Labels.version
0272         const QString newVersion = jsonDocument.object().value(QLatin1String("Labels")).toObject().value(QLatin1String("version")).toString();
0273         if (newVersion.isEmpty()) {
0274             qInfo() << "rpm-ostree-notifier: Could not get the version from the container labels";
0275             return;
0276         }
0277 
0278         QVersionNumber newVersionNumber = QVersionNumber::fromString(newVersion);
0279         QVersionNumber currentVersionNumber = QVersionNumber::fromString(m_version);
0280         if (newVersionNumber <= currentVersionNumber) {
0281             qInfo() << "rpm-ostree-notifier: No new version found";
0282             return;
0283         }
0284 
0285         // Have we already notified the user about this update?
0286         if (newVersion == m_updateVersion) {
0287             qInfo() << "rpm-ostree-notifier: New version has already been offered. Skipping.";
0288             return;
0289         }
0290         m_updateVersion = newVersion;
0291 
0292         // Look for an existing deployment with this version
0293         checkForPendingDeployment();
0294     });
0295 
0296     // This will fail on non-remote transports (oci, oci-archive, containers-storage) but that's
0297     // OK as we can not check for updates in those cases.
0298     m_process->start(QStringLiteral("skopeo"),
0299                      {QStringLiteral("inspect"), QStringLiteral("docker://") + m_ostreeFormat->repo() + QStringLiteral(":") + m_ostreeFormat->tag()});
0300 }
0301 
0302 void RpmOstreeNotifier::checkForPendingDeployment()
0303 {
0304     qInfo() << "rpm-ostree-notifier: Looking at existing deployments";
0305     m_process = new QProcess(this);
0306     m_stdout = QByteArray();
0307 
0308     // Display stderr
0309     connect(m_process, &QProcess::readyReadStandardError, [this]() {
0310         QByteArray message = m_process->readAllStandardError();
0311         qWarning() << "rpm-ostree (error):" << message;
0312     });
0313 
0314     // Store stdout to process as JSON
0315     connect(m_process, &QProcess::readyReadStandardOutput, [this]() {
0316         QByteArray message = m_process->readAllStandardOutput();
0317         m_stdout += message;
0318     });
0319 
0320     // Process command result
0321     connect(m_process, &QProcess::finished, [this](int exitCode, QProcess::ExitStatus exitStatus) {
0322         m_process->deleteLater();
0323         m_process = nullptr;
0324         if (exitStatus != QProcess::NormalExit) {
0325             qWarning() << "rpm-ostree-notifier: Failed to check for existing deployments";
0326             return;
0327         }
0328         if (exitCode != 0) {
0329             // Unexpected error
0330             qWarning() << "rpm-ostree-notifier: Failed to check for existing deployments. Exit code:" << exitCode;
0331             return;
0332         }
0333 
0334         // Parse stdout as JSON and look at the deployments for a pending
0335         // deployment for the new version.
0336         const QJsonDocument jsonDocument = QJsonDocument::fromJson(m_stdout);
0337         if (!jsonDocument.isObject()) {
0338             qWarning() << "rpm-ostree-notifier: Could not parse 'rpm-ostree status' output as JSON";
0339             return;
0340         }
0341         const QJsonArray deployments = jsonDocument.object().value(QLatin1String("deployments")).toArray();
0342         if (deployments.isEmpty()) {
0343             qWarning() << "rpm-ostree-notifier: Could not find the deployments in 'rpm-ostree status' JSON output";
0344             return;
0345         }
0346         QString version;
0347         for (const QJsonValue &deployment : deployments) {
0348             version = deployment.toObject()[QLatin1String("base-version")].toString();
0349             if (version.isEmpty()) {
0350                 version = deployment.toObject()[QLatin1String("version")].toString();
0351             }
0352             if (version.isEmpty()) {
0353                 qInfo() << "rpm-ostree-notifier: Could not read version for deployment:" << deployment;
0354                 continue;
0355             }
0356             if (version == m_updateVersion) {
0357                 qInfo() << "rpm-ostree-notifier: Found an existing deployment for the update available";
0358                 if (!m_needsReboot) {
0359                     qInfo() << "rpm-ostree-notifier: Notifying that a reboot is needed";
0360                     m_needsReboot = true;
0361                     Q_EMIT needsRebootChanged();
0362                 }
0363                 return;
0364             }
0365         }
0366 
0367         // Reaching here means that no deployment has been found for the new version.
0368         qInfo() << "rpm-ostree-notifier: Notifying that a new update is available";
0369         m_hasUpdates = true;
0370         Q_EMIT foundUpdates();
0371 
0372         // TODO: Look for security updates fixed by this new deployment
0373     });
0374 
0375     m_process->start(QStringLiteral("rpm-ostree"), {QStringLiteral("status"), QStringLiteral("--json")});
0376 }
0377 
0378 bool RpmOstreeNotifier::hasSecurityUpdates()
0379 {
0380     return false;
0381 }
0382 
0383 bool RpmOstreeNotifier::needsReboot() const
0384 {
0385     return m_needsReboot;
0386 }
0387 
0388 bool RpmOstreeNotifier::hasUpdates()
0389 {
0390     return m_hasUpdates;
0391 }