Skip to content

Commit 037ab48

Browse files
VMBackup: Fix Python 3.12+ compatibility for Azure Linux 4.0
The VMSnapshot extension crashes on Azure Linux 4.0 (Python 3.14) due to four Python APIs removed in recent versions. The extension fails at import time, causing Azure Backup jobs to fail with error 400039 after hours of retries. Root cause — removed Python APIs used by VMBackup: - distutils.version.LooseVersion (removed in 3.12) - imp module (removed in 3.12) - platform.dist() (removed in 3.8) - platform.linux_distribution() (removed in 3.8) Changes: WaagentLib.py: - Add try/except for LooseVersion import with a minimal shim class that provides comparison operators (__lt__, __gt__, __eq__, __le__, __ge__) using regex-based version parsing. Note: Python never added a stdlib replacement for LooseVersion. PEP 632 recommends the third-party `packaging` library, but it is not suitable here: (1) it is not in the stdlib and may be absent on minimal installs, (2) it enforces strict PEP 440 parsing and rejects the loose version strings this codebase passes, and (3) adding a dependency to a VM extension that must run on every Linux distro from RHEL 7 (Python 2.7) to AZL4 (Python 3.14) is fragile. The inline shim is zero-dependency and drop-in compatible. WAAgentUtil.py: - Add try/except around the `import imp` fallback so it does not crash on Python 3.12+ when the primary importlib path fails HandlerUtil.py: - Replace platform.dist() in get_dist_info() with /etc/os-release parsing (NAME + VERSION fields) - Fix invalid escape sequence: replace('\/', '/') was a no-op (invalid escape treated as literal char); changed to replace('\\/', '/') which correctly replaces \/ with / patch/__init__.py: - Add /etc/os-release ID parsing to DistInfo() fallback - Add 'azurelinux' mapping in GetMyPatching() -> AzureLinuxPatching patch/AzureLinuxPatching.py (new): - Dedicated patching class for Azure Linux 4.0 - All binary paths set to /usr/bin/ (AZL4 merged /usr) - Uses dnf for package installation Tested: - End-to-end Azure Backup on AZL4 VM: PASS (26 min, all phases) - Backward compat across 10 distros (Python 3.6-3.14): all PASS RHEL 8/9, Oracle Linux 9, SLES 15/16, Debian 12, Ubuntu 20.04/22.04/24.04, Azure Linux 4.0
1 parent 1c1db24 commit 037ab48

5 files changed

Lines changed: 146 additions & 8 deletions

File tree

VMBackup/main/Utils/HandlerUtil.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -627,8 +627,20 @@ def get_dist_info(self):
627627
distinfo[0] = distinfo[0].strip()
628628
return distinfo[0]+"-"+distinfo[1],platform.release()
629629
else:
630-
distinfo = platform.dist()
631-
return distinfo[0]+"-"+distinfo[1],platform.release()
630+
# platform.dist() removed in Python 3.8; parse /etc/os-release instead
631+
try:
632+
with open("/etc/os-release", "r") as f:
633+
os_name, os_version = "Unknown", "Unknown"
634+
for line in f:
635+
k, _, v = line.strip().partition("=")
636+
v = v.strip('"')
637+
if k == "NAME":
638+
os_name = v
639+
elif k == "VERSION":
640+
os_version = v
641+
return os_name + "-" + os_version, platform.release()
642+
except Exception:
643+
return "Unknown", "Unknown"
632644
except Exception as e:
633645
errMsg = 'Failed to retrieve the distinfo with error: %s, stack trace: %s' % (str(e), traceback.format_exc())
634646
self.log(errMsg)
@@ -715,7 +727,7 @@ def do_status_report(self, operation, status, status_code, message, taskId = Non
715727
stat_rept.timestampUTC = date_place_holder
716728
date_string = r'\/Date(' + str((int)(time_span)) + r')\/'
717729
stat_rept = "[" + json.dumps(stat_rept, cls = ComplexEncoder) + "]"
718-
stat_rept = stat_rept.replace('\\\/', '\/') # To fix the datetime format of CreationTime to be consumed by C# DateTimeOffset
730+
stat_rept = stat_rept.replace('\\/', '/') # To fix the datetime format of CreationTime to be consumed by C# DateTimeOffset
719731
stat_rept = stat_rept.replace(date_place_holder,date_string)
720732

721733
# Add Status as sub-status for Status to be written on Status-File

VMBackup/main/Utils/WAAgentUtil.py

100644100755
Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,11 @@ def searchWAAgentOld():
6666
spec.loader.exec_module(waagent)
6767
except ImportError:
6868
# For Python 3.4 and earlier, use imp module
69-
import imp
70-
waagent = imp.load_source('waagent', agentPath)
71-
except Exception:
72-
raise Exception("Can't load waagent.")
69+
try:
70+
import imp
71+
waagent = imp.load_source('waagent', agentPath)
72+
except ImportError:
73+
raise Exception("Can't load waagent: importlib.util and imp both unavailable.")
7374
else:
7475
raise Exception("Can't load new or old waagent. Agent path not found.")
7576
except Exception as e:

VMBackup/main/WaagentLib.py

100644100755
Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,48 @@
5656
import json
5757
import datetime
5858
import xml.sax.saxutils
59-
from distutils.version import LooseVersion
59+
try:
60+
from distutils.version import LooseVersion
61+
except ImportError:
62+
# distutils removed in Python 3.12; minimal shim for version comparisons
63+
import re as _re
64+
class LooseVersion(object):
65+
def __init__(self, vstring):
66+
self.vstring = str(vstring)
67+
self._cmp_key = self._parse(self.vstring)
68+
@staticmethod
69+
def _parse(s):
70+
parts = []
71+
for tok in _re.split(r'(\d+)', s):
72+
if tok.isdigit():
73+
parts.append(int(tok))
74+
elif tok:
75+
parts.append(tok)
76+
return parts
77+
def __str__(self):
78+
return self.vstring
79+
def __repr__(self):
80+
return 'LooseVersion(%r)' % self.vstring
81+
def _cmp(self, other):
82+
if not isinstance(other, LooseVersion):
83+
other = LooseVersion(other)
84+
a, b = self._cmp_key, other._cmp_key
85+
for i in range(max(len(a), len(b))):
86+
ai = a[i] if i < len(a) else 0
87+
bi = b[i] if i < len(b) else 0
88+
if type(ai) != type(bi):
89+
ai, bi = str(ai), str(bi)
90+
if ai < bi:
91+
return -1
92+
if ai > bi:
93+
return 1
94+
return 0
95+
def __lt__(self, other): return self._cmp(other) < 0
96+
def __le__(self, other): return self._cmp(other) <= 0
97+
def __eq__(self, other): return self._cmp(other) == 0
98+
def __ge__(self, other): return self._cmp(other) >= 0
99+
def __gt__(self, other): return self._cmp(other) > 0
100+
def __ne__(self, other): return self._cmp(other) != 0
60101

61102
if not hasattr(subprocess, 'check_output'):
62103
def check_output(*popenargs, **kwargs):
@@ -4514,6 +4555,8 @@ def GetMyDistro(dist_class_name=''):
45144555
Distro = 'oracle'
45154556
elif ('redhat'.lower() in Distro.lower()):
45164557
Distro = 'redhat'
4558+
elif ('azurelinux' in Distro.lower()):
4559+
Distro = 'fedora'
45174560
elif ('Kali'.lower() in Distro.lower()):
45184561
Distro = 'Kali'
45194562
elif ('FreeBSD'.lower() in Distro.lower() or 'gaia'.lower() in Distro.lower() or 'panos'.lower() in Distro.lower()):
@@ -4555,6 +4598,21 @@ def DistInfo(fullname=0):
45554598
return distinfo
45564599
if 'Linux' in platform.system():
45574600
distinfo = ["Default"]
4601+
# On Python 3.8+ linux_distribution is removed; detect via /etc/os-release
4602+
try:
4603+
with open("/etc/os-release", "r") as f:
4604+
os_id, os_version = "", ""
4605+
for line in f:
4606+
k, _, v = line.strip().partition("=")
4607+
v = v.strip('"')
4608+
if k == "ID":
4609+
os_id = v
4610+
elif k == "VERSION_ID":
4611+
os_version = v
4612+
if os_id == "azurelinux":
4613+
return ["azurelinux", os_version]
4614+
except Exception:
4615+
pass
45584616
if "ubuntu" in platform.version().lower():
45594617
distinfo[0] = "Ubuntu"
45604618
elif 'suse' in platform.version().lower():
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/python
2+
#
3+
# Copyright 2015 Microsoft Corporation
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
18+
import subprocess
19+
from patch.AbstractPatching import AbstractPatching
20+
from common import *
21+
22+
23+
class AzureLinuxPatching(AbstractPatching):
24+
def __init__(self,logger,distro_info):
25+
super(AzureLinuxPatching,self).__init__(distro_info)
26+
self.logger = logger
27+
self.base64_path = '/usr/bin/base64'
28+
self.bash_path = '/usr/bin/bash'
29+
self.blkid_path = '/usr/bin/blkid'
30+
self.cat_path = '/usr/bin/cat'
31+
self.cryptsetup_path = '/usr/bin/cryptsetup'
32+
self.dd_path = '/usr/bin/dd'
33+
self.e2fsck_path = '/usr/bin/e2fsck'
34+
self.echo_path = '/usr/bin/echo'
35+
self.getenforce_path = '/usr/bin/getenforce'
36+
self.setenforce_path = '/usr/bin/setenforce'
37+
self.lsblk_path = '/usr/bin/lsblk'
38+
self.usr_flag = 1
39+
self.lsscsi_path = '/usr/bin/lsscsi'
40+
self.mkdir_path = '/usr/bin/mkdir'
41+
self.mount_path = '/usr/bin/mount'
42+
self.openssl_path = '/usr/bin/openssl'
43+
self.resize2fs_path = '/usr/bin/resize2fs'
44+
self.umount_path = '/usr/bin/umount'
45+
46+
def install_extras(self):
47+
common_extras = ['cryptsetup','lsscsi']
48+
for extra in common_extras:
49+
self.logger.log("installation for " + extra + ' result is ' + str(subprocess.call(['dnf', 'install','-y', extra])))

VMBackup/main/patch/__init__.py

100644100755
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from patch.DefaultPatching import DefaultPatching
3232
from patch.FreeBSDPatching import FreeBSDPatching
3333
from patch.NSBSDPatching import NSBSDPatching
34+
from patch.AzureLinuxPatching import AzureLinuxPatching
3435

3536
# Define the function in case waagent(<2.0.4) doesn't have DistInfo()
3637
def DistInfo():
@@ -60,6 +61,21 @@ def DistInfo():
6061
return distinfo
6162
if 'Linux' in platform.system():
6263
distinfo = ["Default"]
64+
# On Python 3.8+ linux_distribution is removed; detect via /etc/os-release
65+
try:
66+
with open("/etc/os-release", "r") as f:
67+
os_id, os_version = "", ""
68+
for line in f:
69+
k, _, v = line.strip().partition("=")
70+
v = v.strip('"')
71+
if k == "ID":
72+
os_id = v
73+
elif k == "VERSION_ID":
74+
os_version = v
75+
if os_id == "azurelinux":
76+
return ["azurelinux", os_version]
77+
except Exception:
78+
pass
6379
if "ubuntu" in platform.version().lower():
6480
distinfo[0] = "Ubuntu"
6581
elif 'suse' in platform.version().lower():
@@ -114,6 +130,8 @@ def GetMyPatching(logger):
114130
Distro = 'oracle'
115131
elif ('redhat'.lower() in Distro.lower()):
116132
Distro = 'redhat'
133+
elif ('azurelinux' in Distro.lower()):
134+
Distro = 'AzureLinux'
117135
elif ("Kali".lower() in Distro.lower()):
118136
Distro = 'Kali'
119137
elif ('FreeBSD'.lower() in Distro.lower() or 'gaia'.lower() in Distro.lower() or 'panos'.lower() in Distro.lower()):

0 commit comments

Comments
 (0)