Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Wait on network to be available before trying to run backups against remote repos #2176

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/vorta/network_status/abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
from datetime import datetime
from typing import List, NamedTuple, Optional

from PyQt6.QtCore import QObject, pyqtSignal

class NetworkStatusMonitor:

class NetworkStatusMonitor(QObject):
@classmethod
def get_network_status_monitor(cls) -> 'NetworkStatusMonitor':
if sys.platform == 'darwin':
Expand All @@ -22,10 +24,22 @@ def get_network_status_monitor(cls) -> 'NetworkStatusMonitor':
except (UnsupportedException, DBusException):
return NullNetworkStatusMonitor()

network_status_changed = pyqtSignal(bool, name="networkStatusChanged")

def __init__(self, parent=None):
super().__init__(parent)

def is_network_status_available(self):
"""Is the network status really available, and not just a dummy implementation?"""
return type(self) is not NetworkStatusMonitor

def is_network_active(self) -> bool:
"""Is there an active network connection.

True signals that the network is up. The internet may still not be reachable though.
"""
raise NotImplementedError()

def is_network_metered(self) -> bool:
"""Is the currently connected network a metered connection?"""
raise NotImplementedError()
Expand All @@ -47,6 +61,12 @@ class SystemWifiInfo(NamedTuple):
class NullNetworkStatusMonitor(NetworkStatusMonitor):
"""Dummy implementation, in case we don't have one for current platform."""

def __init__(self):
super().__init__()

def is_network_active(self):
return True

def is_network_status_available(self):
return False

Expand Down
7 changes: 7 additions & 0 deletions src/vorta/network_status/darwin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@


class DarwinNetworkStatus(NetworkStatusMonitor):
def __init__(self):
super().__init__()

def is_network_metered(self) -> bool:
interface: CWInterface = self._get_wifi_interface()

Expand All @@ -25,6 +28,10 @@ def is_network_metered(self) -> bool:

return is_ios_hotspot or any(is_network_metered_with_android(d) for d in get_network_devices())

def is_network_active(self):
# Not yet implemented
return True

def get_current_wifi(self) -> Optional[str]:
"""
Get current SSID or None if Wi-Fi is off.
Expand Down
37 changes: 36 additions & 1 deletion src/vorta/network_status/network_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import Any, List, Mapping, NamedTuple, Optional

from PyQt6 import QtDBus
from PyQt6.QtCore import QObject, QVersionNumber
from PyQt6.QtCore import QObject, QVersionNumber, pyqtSignal, pyqtSlot

from vorta.network_status.abc import NetworkStatusMonitor, SystemWifiInfo

Expand All @@ -13,7 +13,9 @@

class NetworkManagerMonitor(NetworkStatusMonitor):
def __init__(self, nm_adapter: 'NetworkManagerDBusAdapter' = None):
super().__init__()
self._nm = nm_adapter or NetworkManagerDBusAdapter.get_system_nm_adapter()
self._nm.network_status_changed.connect(self.network_status_changed)

def is_network_metered(self) -> bool:
try:
Expand All @@ -25,6 +27,13 @@ def is_network_metered(self) -> bool:
logger.exception("Failed to check if network is metered, assuming it isn't")
return False

def is_network_active(self):
try:
return self._nm.get_connectivity_state() is not NMConnectivityState.NONE
except DBusException:
logger.exception("Failed to check connectivity state. Assuming connected")
return True

def get_current_wifi(self) -> Optional[str]:
# Only check the primary connection. VPN over WiFi will still show the WiFi as Primary Connection.
# We don't check all active connections, as NM won't disable WiFi when connecting a cable.
Expand Down Expand Up @@ -98,10 +107,17 @@ class NetworkManagerDBusAdapter(QObject):

BUS_NAME = 'org.freedesktop.NetworkManager'
NM_PATH = '/org/freedesktop/NetworkManager'
INTERFACE_NAME = 'org.freedesktop.NetworkManager'
SIGNAL_NAME = 'StateChanged'

network_status_changed = pyqtSignal(bool, name="networkStatusChanged")

def __init__(self, parent, bus):
super().__init__(parent)
self._bus = bus
self._bus.connect(
self.BUS_NAME, self.NM_PATH, self.INTERFACE_NAME, self.SIGNAL_NAME, 'u', self.networkStateChanged
)
self._nm = self._get_iface(self.NM_PATH, 'org.freedesktop.NetworkManager')

@classmethod
Expand All @@ -114,6 +130,12 @@ def get_system_nm_adapter(cls) -> 'NetworkManagerDBusAdapter':
raise UnsupportedException("Can't connect to NetworkManager")
return nm_adapter

@pyqtSlot("unsigned int")
def networkStateChanged(self, state):
logger.debug(f'network state changed: {state}')
# https://www.networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMState
self.network_status_changed.emit(state >= 60)

def isValid(self):
if not self._nm.isValid():
return False
Expand All @@ -126,6 +148,9 @@ def isValid(self):
return False
return True

def get_connectivity_state(self) -> 'NMConnectivityState':
return NMConnectivityState(read_dbus_property(self._nm, 'Connectivity'))

def get_primary_connection_path(self) -> Optional[str]:
return read_dbus_property(self._nm, 'PrimaryConnection')

Expand Down Expand Up @@ -186,3 +211,13 @@ class NMDeviceType(Enum):
# Only the types we care about
UNKNOWN = 0
WIFI = 2


class NMConnectivityState(Enum):
"""https://www.networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMConnectivityState"""

UNKNOWN = 0
NONE = 1
PORTAL = 2
LIMITED = 3
FULL = 4
34 changes: 30 additions & 4 deletions src/vorta/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import threading
from datetime import datetime as dt
from datetime import timedelta
from typing import Dict, NamedTuple, Optional, Tuple, Union
from typing import Dict, List, NamedTuple, Optional, Tuple, Union

from packaging import version
from PyQt6 import QtCore, QtDBus
Expand All @@ -19,7 +19,7 @@
from vorta.i18n import translate
from vorta.notifications import VortaNotifications
from vorta.store.models import BackupProfileModel, EventLogModel
from vorta.utils import borg_compat
from vorta.utils import borg_compat, get_network_status_monitor

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -62,6 +62,11 @@ def __init__(self):
# connect signals
self.app.backup_finished_event.connect(lambda res: self.set_timer_for_profile(res['params']['profile_id']))

# Connect to network manager to to monitor that network status
self.net_status = get_network_status_monitor()
self.net_status.network_status_changed.connect(self.networkStatusChanged)
self._net_up = self.net_status.is_network_active()

# connect to `systemd-logind` to receive sleep/resume events
# The signal `PrepareForSleep` will be emitted before and after hibernation.
service = "org.freedesktop.login1"
Expand All @@ -79,6 +84,17 @@ def __init__(self):
def loginSuspendNotify(self, suspend: bool):
if not suspend:
logger.debug("Got login suspend/resume notification")
# Defensively refetch in case the network status didn't arrive
self._net_up = self.net_status.is_network_active()
self.reload_all_timers()

@QtCore.pyqtSlot(bool)
def networkStatusChanged(self, up: bool):
reload = self._net_up != up
self._net_up = up
logger.debug(f"network status up={up}")
if reload:
logger.info("updating shcedule due to network status change")
self.reload_all_timers()

def tr(self, *args, **kwargs):
Expand Down Expand Up @@ -293,7 +309,7 @@ def set_timer_for_profile(self, profile_id: int):

# handle missing of a scheduled time
if next_time <= dt.now():
if profile.schedule_make_up_missed:
if profile.schedule_make_up_missed and self._net_up:
self.lock.release()
try:
logger.debug(
Expand All @@ -306,6 +322,8 @@ def set_timer_for_profile(self, profile_id: int):
self.lock.acquire() # with-statement will try to release

return # create_backup will lead to a call to this method
else:
logger.debug('Skipping catchup %s (%s), the network is not available', profile.name, profile.id)

# calculate next time from now
if profile.schedule_mode == 'interval':
Expand Down Expand Up @@ -361,7 +379,15 @@ def set_timer_for_profile(self, profile_id: int):
def reload_all_timers(self):
logger.debug('Refreshing all scheduler timers')
for profile in BackupProfileModel.select():
self.set_timer_for_profile(profile.id)
# Only set a timer for the profile if the network is actually up
if profile.repo is None:
logger.debug("nothing scheduled for %s because of unset repo", profile.id)
elif not profile.repo.is_remote_repo() or self._net_up:
logger.debug("scheduling %s", profile.id)
self.set_timer_for_profile(profile.id)
else:
logger.debug("Network is down, not scheduling %s", profile.id)
self.remove_job(profile.id)

def next_job(self):
now = dt.now()
Expand Down