diff --git a/src/migration-scripts/bgp/0-to-1 b/src/migration-scripts/bgp/0-to-1
old mode 100755
new mode 100644
index 5b8e8a163..a2f3343d8
--- a/src/migration-scripts/bgp/0-to-1
+++ b/src/migration-scripts/bgp/0-to-1
@@ -1,60 +1,40 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3417: migrate BGP tagNode to node as we can only have one BGP process
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-base = ['protocols', 'bgp']
-config = ConfigTree(config_file)
-
-if not config.exists(base) or not config.is_tag(base):
-    # Nothing to do
-    exit(0)
+def migrate(config: ConfigTree) -> None:
+    base = ['protocols', 'bgp']
 
-# Only one BGP process is supported, thus this operation is savea
-asn = config.list_nodes(base)
-bgp_base = base + asn
+    if not config.exists(base) or not config.is_tag(base):
+        # Nothing to do
+        return
 
-# We need a temporary copy of the config
-tmp_base = ['protocols', 'bgp2']
-config.copy(bgp_base, tmp_base)
+    # Only one BGP process is supported, thus this operation is savea
+    asn = config.list_nodes(base)
+    bgp_base = base + asn
 
-# Now it's save to delete the old configuration
-config.delete(base)
+    # We need a temporary copy of the config
+    tmp_base = ['protocols', 'bgp2']
+    config.copy(bgp_base, tmp_base)
 
-# Rename temporary copy to new final config and set new "local-as" option
-config.rename(tmp_base, 'bgp')
-config.set(base + ['local-as'], value=asn[0])
+    # Now it's save to delete the old configuration
+    config.delete(base)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+    # Rename temporary copy to new final config and set new "local-as" option
+    config.rename(tmp_base, 'bgp')
+    config.set(base + ['local-as'], value=asn[0])
diff --git a/src/migration-scripts/bgp/1-to-2 b/src/migration-scripts/bgp/1-to-2
old mode 100755
new mode 100644
index a40d86e67..c0fc3b05a
--- a/src/migration-scripts/bgp/1-to-2
+++ b/src/migration-scripts/bgp/1-to-2
@@ -1,84 +1,64 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3741: no-ipv4-unicast is now enabled by default
 # T5937: Migrate IPv6 BGP Neighbor Peer Groups
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
+base = ['protocols', 'bgp']
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-base = ['protocols', 'bgp']
-config = ConfigTree(config_file)
+    # This is now a default option - simply delete it.
+    # As it was configured explicitly - we can also bail out early as we need to
+    # do nothing!
+    if config.exists(base + ['parameters', 'default', 'no-ipv4-unicast']):
+        config.delete(base + ['parameters', 'default', 'no-ipv4-unicast'])
 
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
+        # Check if the "default" node is now empty, if so - remove it
+        if len(config.list_nodes(base + ['parameters', 'default'])) == 0:
+            config.delete(base + ['parameters', 'default'])
 
-# This is now a default option - simply delete it.
-# As it was configured explicitly - we can also bail out early as we need to
-# do nothing!
-if config.exists(base + ['parameters', 'default', 'no-ipv4-unicast']):
-    config.delete(base + ['parameters', 'default', 'no-ipv4-unicast'])
+        # Check if the "default" node is now empty, if so - remove it
+        if len(config.list_nodes(base + ['parameters'])) == 0:
+            config.delete(base + ['parameters'])
+    else:
+        # As we now install a new default option into BGP we need to migrate all
+        # existing BGP neighbors and restore the old behavior
+        if config.exists(base + ['neighbor']):
+            for neighbor in config.list_nodes(base + ['neighbor']):
+                peer_group = base + ['neighbor', neighbor, 'peer-group']
+                if config.exists(peer_group):
+                    peer_group_name = config.return_value(peer_group)
+                    # peer group enables old behavior for neighbor - bail out
+                    if config.exists(base + ['peer-group', peer_group_name, 'address-family', 'ipv4-unicast']):
+                        continue
 
-    # Check if the "default" node is now empty, if so - remove it
-    if len(config.list_nodes(base + ['parameters', 'default'])) == 0:
-        config.delete(base + ['parameters', 'default'])
+                afi_ipv4 = base + ['neighbor', neighbor, 'address-family', 'ipv4-unicast']
+                if not config.exists(afi_ipv4):
+                    config.set(afi_ipv4)
 
-    # Check if the "default" node is now empty, if so - remove it
-    if len(config.list_nodes(base + ['parameters'])) == 0:
-        config.delete(base + ['parameters'])
-else:
-    # As we now install a new default option into BGP we need to migrate all
-    # existing BGP neighbors and restore the old behavior
+    # Migrate IPv6 AFI peer-group
     if config.exists(base + ['neighbor']):
         for neighbor in config.list_nodes(base + ['neighbor']):
-            peer_group = base + ['neighbor', neighbor, 'peer-group']
-            if config.exists(peer_group):
-                peer_group_name = config.return_value(peer_group)
-                # peer group enables old behavior for neighbor - bail out
-                if config.exists(base + ['peer-group', peer_group_name, 'address-family', 'ipv4-unicast']):
-                    continue
-
-            afi_ipv4 = base + ['neighbor', neighbor, 'address-family', 'ipv4-unicast']
-            if not config.exists(afi_ipv4):
-                config.set(afi_ipv4)
-
-# Migrate IPv6 AFI peer-group
-if config.exists(base + ['neighbor']):
-    for neighbor in config.list_nodes(base + ['neighbor']):
-        tmp_path = base + ['neighbor', neighbor, 'address-family', 'ipv6-unicast', 'peer-group']
-        if config.exists(tmp_path):
-            peer_group = config.return_value(tmp_path)
-            config.set(base + ['neighbor', neighbor, 'peer-group'], value=peer_group)
-            config.delete(tmp_path)
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+            tmp_path = base + ['neighbor', neighbor, 'address-family', 'ipv6-unicast', 'peer-group']
+            if config.exists(tmp_path):
+                peer_group = config.return_value(tmp_path)
+                config.set(base + ['neighbor', neighbor, 'peer-group'], value=peer_group)
+                config.delete(tmp_path)
diff --git a/src/migration-scripts/bgp/2-to-3 b/src/migration-scripts/bgp/2-to-3
old mode 100755
new mode 100644
index 34d321a96..d8bc34db6
--- a/src/migration-scripts/bgp/2-to-3
+++ b/src/migration-scripts/bgp/2-to-3
@@ -1,51 +1,30 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T4257: Discussion on changing BGP autonomous system number syntax
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
-# Check if BGP is even configured. Then check if local-as exists, then add the system-as, then remove the local-as. This is for global configuration.
-if config.exists(['protocols', 'bgp']):
-    if config.exists(['protocols', 'bgp', 'local-as']):
-        config.rename(['protocols', 'bgp', 'local-as'], 'system-as')
-
-# Check if vrf names are configured. Then check if local-as exists inside of a name, then add the system-as, then remove the local-as. This is for vrf configuration.
-if config.exists(['vrf', 'name']):
-    for vrf in config.list_nodes(['vrf', 'name']):
-        if config.exists(['vrf', f'name {vrf}', 'protocols', 'bgp', 'local-as']):
-            config.rename(['vrf', f'name {vrf}', 'protocols', 'bgp', 'local-as'], 'system-as')
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    # Check if BGP is even configured. Then check if local-as exists, then add the system-as, then remove the local-as. This is for global configuration.
+    if config.exists(['protocols', 'bgp']):
+        if config.exists(['protocols', 'bgp', 'local-as']):
+            config.rename(['protocols', 'bgp', 'local-as'], 'system-as')
+
+    # Check if vrf names are configured. Then check if local-as exists inside of a name, then add the system-as, then remove the local-as. This is for vrf configuration.
+    if config.exists(['vrf', 'name']):
+        for vrf in config.list_nodes(['vrf', 'name']):
+            if config.exists(['vrf', f'name {vrf}', 'protocols', 'bgp', 'local-as']):
+                config.rename(['vrf', f'name {vrf}', 'protocols', 'bgp', 'local-as'], 'system-as')
diff --git a/src/migration-scripts/bgp/3-to-4 b/src/migration-scripts/bgp/3-to-4
old mode 100755
new mode 100644
index 894cdda2b..842aef0ce
--- a/src/migration-scripts/bgp/3-to-4
+++ b/src/migration-scripts/bgp/3-to-4
@@ -1,64 +1,43 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5150: Rework CLI definitions to apply route-maps between routing daemons
 #        and zebra/kernel
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
-bgp_base = ['protocols', 'bgp']
-# Check if BGP is configured - if so, migrate the CLI node
-if config.exists(bgp_base):
-    if config.exists(bgp_base + ['route-map']):
-        tmp = config.return_value(bgp_base + ['route-map'])
-
-        config.set(['system', 'ip', 'protocol', 'bgp', 'route-map'], value=tmp)
-        config.set_tag(['system', 'ip', 'protocol'])
-        config.delete(bgp_base + ['route-map'])
-
-
-# Check if vrf names are configured. Check if BGP is configured - if so, migrate
-# the CLI node(s)
-if config.exists(['vrf', 'name']):
-    for vrf in config.list_nodes(['vrf', 'name']):
-        vrf_base = ['vrf', 'name', vrf]
-        if config.exists(vrf_base + ['protocols', 'bgp', 'route-map']):
-            tmp = config.return_value(vrf_base + ['protocols', 'bgp', 'route-map'])
-
-            config.set(vrf_base + ['ip', 'protocol', 'bgp', 'route-map'], value=tmp)
-            config.set_tag(vrf_base + ['ip', 'protocol', 'bgp'])
-            config.delete(vrf_base + ['protocols', 'bgp', 'route-map'])
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    bgp_base = ['protocols', 'bgp']
+    # Check if BGP is configured - if so, migrate the CLI node
+    if config.exists(bgp_base):
+        if config.exists(bgp_base + ['route-map']):
+            tmp = config.return_value(bgp_base + ['route-map'])
+
+            config.set(['system', 'ip', 'protocol', 'bgp', 'route-map'], value=tmp)
+            config.set_tag(['system', 'ip', 'protocol'])
+            config.delete(bgp_base + ['route-map'])
+
+
+    # Check if vrf names are configured. Check if BGP is configured - if so, migrate
+    # the CLI node(s)
+    if config.exists(['vrf', 'name']):
+        for vrf in config.list_nodes(['vrf', 'name']):
+            vrf_base = ['vrf', 'name', vrf]
+            if config.exists(vrf_base + ['protocols', 'bgp', 'route-map']):
+                tmp = config.return_value(vrf_base + ['protocols', 'bgp', 'route-map'])
+
+                config.set(vrf_base + ['ip', 'protocol', 'bgp', 'route-map'], value=tmp)
+                config.set_tag(vrf_base + ['ip', 'protocol', 'bgp'])
+                config.delete(vrf_base + ['protocols', 'bgp', 'route-map'])
diff --git a/src/migration-scripts/bgp/4-to-5 b/src/migration-scripts/bgp/4-to-5
old mode 100755
new mode 100644
index c4eb9ec72..d779eb11e
--- a/src/migration-scripts/bgp/4-to-5
+++ b/src/migration-scripts/bgp/4-to-5
@@ -1,67 +1,46 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Delete 'protocols bgp address-family ipv6-unicast route-target vpn
 # import/export', if 'protocols bgp address-family ipv6-unicast
 # route-target vpn both' exists
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
-bgp_base = ['protocols', 'bgp']
-# Delete 'import/export' in default vrf if 'both' exists
-if config.exists(bgp_base):
-    for address_family in ['ipv4-unicast', 'ipv6-unicast']:
-        rt_path = bgp_base + ['address-family', address_family, 'route-target',
-                              'vpn']
-        if config.exists(rt_path + ['both']):
-            if config.exists(rt_path + ['import']):
-                config.delete(rt_path + ['import'])
-            if config.exists(rt_path + ['export']):
-                config.delete(rt_path + ['export'])
-
-# Delete import/export in vrfs if both exists
-if config.exists(['vrf', 'name']):
-    for vrf in config.list_nodes(['vrf', 'name']):
-        vrf_base = ['vrf', 'name', vrf]
+def migrate(config: ConfigTree) -> None:
+    bgp_base = ['protocols', 'bgp']
+    # Delete 'import/export' in default vrf if 'both' exists
+    if config.exists(bgp_base):
         for address_family in ['ipv4-unicast', 'ipv6-unicast']:
-            rt_path = vrf_base + bgp_base + ['address-family', address_family,
-                                             'route-target', 'vpn']
+            rt_path = bgp_base + ['address-family', address_family, 'route-target',
+                                  'vpn']
             if config.exists(rt_path + ['both']):
                 if config.exists(rt_path + ['import']):
                     config.delete(rt_path + ['import'])
                 if config.exists(rt_path + ['export']):
                     config.delete(rt_path + ['export'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+    # Delete import/export in vrfs if both exists
+    if config.exists(['vrf', 'name']):
+        for vrf in config.list_nodes(['vrf', 'name']):
+            vrf_base = ['vrf', 'name', vrf]
+            for address_family in ['ipv4-unicast', 'ipv6-unicast']:
+                rt_path = vrf_base + bgp_base + ['address-family', address_family,
+                                                 'route-target', 'vpn']
+                if config.exists(rt_path + ['both']):
+                    if config.exists(rt_path + ['import']):
+                        config.delete(rt_path + ['import'])
+                    if config.exists(rt_path + ['export']):
+                        config.delete(rt_path + ['export'])
diff --git a/src/migration-scripts/cluster/1-to-2 b/src/migration-scripts/cluster/1-to-2
old mode 100755
new mode 100644
index a2e589155..5ca4531ea
--- a/src/migration-scripts/cluster/1-to-2
+++ b/src/migration-scripts/cluster/1-to-2
@@ -1,193 +1,178 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 import re
 import sys
 
 from vyos.configtree import ConfigTree
+from vyos.base import MigrationError
 
-if __name__ == '__main__':
-    if len(sys.argv) < 2:
-        print("Must specify file name!")
-        sys.exit(1)
-
-    file_name = sys.argv[1]
-
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
-
+def migrate(config: ConfigTree) -> None:
     if not config.exists(['cluster']):
         # Cluster is not set -- nothing to do at all
-        sys.exit(0)
+        return
 
     # If at least one cluster group is defined, we have real work to do.
     # If there are no groups, we remove the top-level cluster node at the end of this script anyway.
     if config.exists(['cluster', 'group']):
         # First, gather timer and interface settings to duplicate them in all groups,
         # since in the old cluster they are global, but in VRRP they are always per-group
 
         global_interface = None
         if config.exists(['cluster', 'interface']):
             global_interface = config.return_value(['cluster', 'interface'])
         else:
             # Such configs shouldn't exist in practice because interface is a required option.
             # But since it's possible to specify interface inside 'service' options,
             # we may be able to convert such configs nonetheless.
             print("Warning: incorrect cluster config: interface is not defined.", file=sys.stderr)
 
         # There are three timers: advertise-interval, dead-interval, and monitor-dead-interval
         # Only the first one makes sense for the VRRP, we translate it to advertise-interval
         advertise_interval = None
         if config.exists(['cluster', 'keepalive-interval']):
             advertise_interval = config.return_value(['cluster', 'keepalive-interval'])
 
         if advertise_interval is not None:
             # Cluster had all timers in milliseconds, so we need to convert them to seconds
             # And ensure they are not shorter than one second
             advertise_interval = int(advertise_interval) // 1000
             if advertise_interval < 1:
                 advertise_interval = 1
 
         # Cluster had password as a global option, in VRRP it's per-group
         password = None
         if config.exists(['cluster', 'pre-shared-secret']):
             password = config.return_value(['cluster', 'pre-shared-secret'])
 
         # Set up the stage for converting cluster groups to VRRP groups
         free_vrids = set(range(1,255))
         vrrp_base_path = ['high-availability', 'vrrp', 'group']
         if not config.exists(vrrp_base_path):
             # If VRRP is not set up, create a node and set it to 'tag node'
             # Setting it to 'tag' is not mandatory but it's better to be consistent
             # with configs produced by 'save'
             config.set(vrrp_base_path)
             config.set_tag(vrrp_base_path)
         else:
             # If there are VRRP groups already, we need to find the set of unused VRID numbers to avoid conflicts
             existing_vrids = set()
             for vg in config.list_nodes(vrrp_base_path):
                 existing_vrids.add(int(config.return_value(vrrp_base_path + [vg, 'vrid'])))
             free_vrids = free_vrids.difference(existing_vrids)
 
         # Now handle cluster groups
         groups = config.list_nodes(['cluster', 'group'])
         for g in groups:
             base_path = ['cluster', 'group', g]
             service_names = config.return_values(base_path + ['service'])
 
             # Cluster used to allow services other than IP addresses, at least nominally
             # Whether that ever worked is a big question, but we need to consider that,
             # since configs with custom services are definitely impossible to meaningfully migrate now
             services = {"ip": [], "other": []}
             for s in service_names:
                 if re.match(r'^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2})(/[a-z]+\d+)?$', s):
                     services["ip"].append(s)
                 else:
                     services["other"].append(s)
 
             if services["other"]:
-                print("Cluster config includes non-IP address services and cannot be migrated", file=sys.stderr)
-                sys.exit(1)
+                err_str = "Cluster config includes non-IP address services and cannot be migrated"
+                print(err_str, file=sys.stderr)
+                raise MigrationError(err_str)
 
             # Cluster allowed virtual IPs for different interfaces within a single group.
             # VRRP groups are by definition bound to interfaces, so we cannot migrate such configurations.
             # Thus we need to find out if all addresses either leave the interface unspecified
             # (in that case the global 'cluster interface' option is used),
             # or have the same interface, or have the same interface as the global 'cluster interface'.
 
             # First, we collect all addresses and check if they have interface specified
             # If not, we substitute the global interface option
             # or throw an error if it's not in the config.
             ips = []
             for ip in services["ip"]:
                 ip_with_intf = re.match(r'^(?P<ip>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2})/(?P<intf>[a-z]+\d+)$', ip)
                 if ip_with_intf:
                     ips.append({"ip": ip_with_intf.group("ip"), "interface": ip_with_intf.group("intf")})
                 else:
                     if global_interface is not None:
                         ips.append({"ip": ip, "interface": global_interface})
                     else:
-                        print("Error: cluster has groups with IPs without interfaces and 'cluster interface' is not specified.", file=sys.stderr)
-                        sys.exit(1)
+                        err_str = "Cluster group has addresses without interfaces and 'cluster interface' is not specified."
+                        print(f'Error: {err_str}', file=sys.stderr)
+                        raise MigrationError(err_str)
 
             # Then we check if all addresses are for the same interface.
             intfs_set = set(map(lambda i: i["interface"], ips))
             if len(intfs_set) > 1:
-                print("Error: cluster group has addresses for different interfaces", file=sys.stderr)
-                sys.exit(1)
+                err_str = "Cluster group has addresses for different interfaces"
+                print(f'Error: {err_str}', file=sys.stderr)
+                raise MigrationError(err_str)
 
             # If we got this far, the group is migratable.
 
             # Extract the interface from the set -- we know there's only a single member.
             interface = intfs_set.pop()
 
             addresses = list(map(lambda i: i["ip"], ips))
             vrrp_path = ['high-availability', 'vrrp', 'group', g]
 
             # If there's already a VRRP group with exactly the same name,
             # we probably shouldn't try to make up a unique name, just leave migration to the user...
             if config.exists(vrrp_path):
-                print("Error: VRRP group with the same name as a cluster group already exists", file=sys.stderr)
-                sys.exit(1)
+                err_str = "VRRP group with the same name already exists"
+                print(f'Error: {err_str}', file=sys.stderr)
+                raise MigrationError(err_str)
 
             config.set(vrrp_path + ['interface'], value=interface)
             for a in addresses:
                 config.set(vrrp_path + ['virtual-address'], value=a, replace=False)
 
             # Take the next free VRID and assign it to the group
             vrid = free_vrids.pop()
             config.set(vrrp_path + ['vrid'], value=vrid)
 
             # Convert the monitor option to VRRP ping health check
             if config.exists(base_path + ['monitor']):
                 monitor_ip = config.return_value(base_path + ['monitor'])
                 config.set(vrrp_path + ['health-check', 'ping'], value=monitor_ip)
 
             # Convert "auto-failback" to "no-preempt", if necessary
             if config.exists(base_path + ['auto-failback']):
                 # It's a boolean node that requires "true" or "false"
                 # so if it exists we still need to check its value
                 auto_failback = config.return_value(base_path + ['auto-failback'])
                 if auto_failback == "false":
                     config.set(vrrp_path + ['no-preempt'])
                 else:
                     # It's "true" or we assume it is, which means preemption is desired,
                     # and in VRRP config it's the default
                     pass
             else:
                 # The old default for that option is false
                 config.set(vrrp_path + ['no-preempt'])
 
             # Inject settings from the global cluster config that have to be per-group in VRRP
             if advertise_interval is not None:
                 config.set(vrrp_path + ['advertise-interval'], value=advertise_interval)
 
             if password is not None:
                 config.set(vrrp_path + ['authentication', 'password'], value=password)
                 config.set(vrrp_path + ['authentication', 'type'], value='plaintext-password')
 
     # Finally, clean up the old cluster node
     config.delete(['cluster'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/config-management/0-to-1 b/src/migration-scripts/config-management/0-to-1
old mode 100755
new mode 100644
index 6528fd136..44c685630
--- a/src/migration-scripts/config-management/0-to-1
+++ b/src/migration-scripts/config-management/0-to-1
@@ -1,31 +1,24 @@
-#!/usr/bin/env python3
+# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Add commit-revisions option if it doesn't exist
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
-if config.exists(['system', 'config-management', 'commit-revisions']):
-    # Nothing to do
-    sys.exit(0)
-else:
+def migrate(config: ConfigTree) -> None:
+    if config.exists(['system', 'config-management', 'commit-revisions']):
+        # Nothing to do
+        return
     config.set(['system', 'config-management', 'commit-revisions'], value='200')
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/conntrack-sync/1-to-2 b/src/migration-scripts/conntrack-sync/1-to-2
old mode 100755
new mode 100644
index a8e1007f3..3e10e98c3
--- a/src/migration-scripts/conntrack-sync/1-to-2
+++ b/src/migration-scripts/conntrack-sync/1-to-2
@@ -1,66 +1,46 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # VyOS 1.2 crux allowed configuring a lower or upper case loglevel. This
 # is no longer supported as the input data is validated and will lead to
 # an error. If user specifies an upper case logleve, make it lowercase
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['service', 'conntrack-sync']
-config = ConfigTree(config_file)
 
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-else:
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
     base_accept_proto = base + ['accept-protocol']
     if config.exists(base_accept_proto):
         tmp = config.return_value(base_accept_proto)
         config.delete(base_accept_proto)
         for protocol in tmp.split(','):
             config.set(base_accept_proto, value=protocol, replace=False)
 
     base_ignore_addr = base + ['ignore-address', 'ipv4']
     if config.exists(base_ignore_addr):
         tmp = config.return_values(base_ignore_addr)
         config.delete(base_ignore_addr)
         for address in tmp:
             config.set(base + ['ignore-address'], value=address, replace=False)
 
     # we no longer support cluster mode
     base_cluster = base + ['failover-mechanism', 'cluster']
     if config.exists(base_cluster):
         config.delete(base_cluster)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/conntrack/1-to-2 b/src/migration-scripts/conntrack/1-to-2
old mode 100755
new mode 100644
index c4fe667fc..0a4fb3de9
--- a/src/migration-scripts/conntrack/1-to-2
+++ b/src/migration-scripts/conntrack/1-to-2
@@ -1,32 +1,26 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Delete "set system conntrack modules gre" option
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(['system', 'conntrack', 'modules', 'gre']):
+        return
 
-config = ConfigTree(config_file)
-
-if not config.exists(['system', 'conntrack', 'modules', 'gre']):
-    # Nothing to do
-    sys.exit(0)
-else:
     # Delete abandoned node
     config.delete(['system', 'conntrack', 'modules', 'gre'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/conntrack/2-to-3 b/src/migration-scripts/conntrack/2-to-3
old mode 100755
new mode 100644
index 6bb42be1e..5ad4e6350
--- a/src/migration-scripts/conntrack/2-to-3
+++ b/src/migration-scripts/conntrack/2-to-3
@@ -1,36 +1,31 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Conntrack syntax version 3
 # Enables all conntrack modules (previous default behaviour) and omits manually disabled modules.
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print('Must specify file name!')
-    sys.exit(1)
-
-filename = sys.argv[1]
-
-with open(filename, 'r') as f:
-    config = ConfigTree(f.read())
-
 module_path = ['system', 'conntrack', 'modules']
 
-# Go over all conntrack modules available as of v1.3.0.
-for module in ['ftp', 'h323', 'nfs', 'pptp', 'sip', 'sqlnet', 'tftp']:
-    # 'disable' is being phased out.
-    if config.exists(module_path + [module, 'disable']):
-        config.delete(module_path + [module])
-    # If it wasn't manually 'disable'd, it was enabled by default.
-    else:
-        config.set(module_path + [module])
-
-try:
-    if config.exists(module_path):
-        with open(filename, 'w') as f:
-            f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    sys.exit(1)
+def migrate(config: ConfigTree) -> None:
+    # Go over all conntrack modules available as of v1.3.0.
+    for module in ['ftp', 'h323', 'nfs', 'pptp', 'sip', 'sqlnet', 'tftp']:
+        # 'disable' is being phased out.
+        if config.exists(module_path + [module, 'disable']):
+            config.delete(module_path + [module])
+        # If it wasn't manually 'disable'd, it was enabled by default.
+        else:
+            config.set(module_path + [module])
diff --git a/src/migration-scripts/conntrack/3-to-4 b/src/migration-scripts/conntrack/3-to-4
old mode 100755
new mode 100644
index e90c383af..679a260d5
--- a/src/migration-scripts/conntrack/3-to-4
+++ b/src/migration-scripts/conntrack/3-to-4
@@ -1,50 +1,30 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Add support for IPv6 conntrack ignore, move existing nodes to `system conntrack ignore ipv4`
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['system', 'conntrack']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
 
-if config.exists(base + ['ignore', 'rule']):
-    config.set(base + ['ignore', 'ipv4'])
-    config.copy(base + ['ignore', 'rule'], base + ['ignore', 'ipv4', 'rule'])
-    config.delete(base + ['ignore', 'rule'])
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    if config.exists(base + ['ignore', 'rule']):
+        config.set(base + ['ignore', 'ipv4'])
+        config.copy(base + ['ignore', 'rule'], base + ['ignore', 'ipv4', 'rule'])
+        config.delete(base + ['ignore', 'rule'])
diff --git a/src/migration-scripts/conntrack/4-to-5 b/src/migration-scripts/conntrack/4-to-5
old mode 100755
new mode 100644
index d2e5fc5fa..775fe7480
--- a/src/migration-scripts/conntrack/4-to-5
+++ b/src/migration-scripts/conntrack/4-to-5
@@ -1,59 +1,39 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5779: system conntrack timeout custom
 # Before:
 #   Protocols tcp, udp and icmp allowed. When using udp it did not work
 #   Only ipv4 custom timeout rules
 # Now:
 #   Valid protocols are only tcp or udp.
 #   Extend functionality to ipv6 and move ipv4 custom rules to new node:
 #       set system conntrack timeout custom [ipv4 | ipv6] rule <rule> ...
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['system', 'conntrack']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-if config.exists(base + ['timeout', 'custom', 'rule']):
-    for rule in config.list_nodes(base + ['timeout', 'custom', 'rule']):
-        if config.exists(base + ['timeout', 'custom', 'rule', rule, 'protocol', 'tcp']):
-            config.set(base + ['timeout', 'custom', 'ipv4', 'rule'])
-            config.copy(base + ['timeout', 'custom', 'rule', rule], base + ['timeout', 'custom', 'ipv4', 'rule', rule])
-    config.delete(base + ['timeout', 'custom', 'rule'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    if config.exists(base + ['timeout', 'custom', 'rule']):
+        for rule in config.list_nodes(base + ['timeout', 'custom', 'rule']):
+            if config.exists(base + ['timeout', 'custom', 'rule', rule, 'protocol', 'tcp']):
+                config.set(base + ['timeout', 'custom', 'ipv4', 'rule'])
+                config.copy(base + ['timeout', 'custom', 'rule', rule], base + ['timeout', 'custom', 'ipv4', 'rule', rule])
+        config.delete(base + ['timeout', 'custom', 'rule'])
diff --git a/src/migration-scripts/container/0-to-1 b/src/migration-scripts/container/0-to-1
old mode 100755
new mode 100644
index 6b282e082..99102a5e6
--- a/src/migration-scripts/container/0-to-1
+++ b/src/migration-scripts/container/0-to-1
@@ -1,77 +1,65 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T4870: change underlaying container filesystem from vfs to overlay
 
 import os
 import shutil
-import sys
 
 from vyos.configtree import ConfigTree
 from vyos.utils.process import call
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['container', 'name']
-config = ConfigTree(config_file)
-
-# Check if containers exist and we need to perform image manipulation
-if config.exists(base):
-    for container in config.list_nodes(base):
-        # Stop any given container first
-        call(f'sudo systemctl stop vyos-container-{container}.service')
-        # Export container image for later re-import to new filesystem. We store
-        # the backup on a real disk as a tmpfs (like /tmp) could probably lack
-        # memory if a host has too many containers stored.
-        image_name = config.return_value(base + [container, 'image'])
-        call(f'sudo podman image save --quiet --output /root/{container}.tar --format oci-archive {image_name}')
 
-# No need to adjust the strage driver online (this is only used for testing and
-# debugging on a live system) - it is already overlay2 when the migration script
-# is run during system update. But the specified driver in the image is actually
-# overwritten by the still present VFS filesystem on disk. Thus podman still
-# thinks it uses VFS until we delete the libpod directory under:
-# /usr/lib/live/mount/persistence/container/storage
-#call('sed -i "s/vfs/overlay2/g" /etc/containers/storage.conf /usr/share/vyos/templates/container/storage.conf.j2')
+def migrate(config: ConfigTree) -> None:
+    # Check if containers exist and we need to perform image manipulation
+    if config.exists(base):
+        for container in config.list_nodes(base):
+            # Stop any given container first
+            call(f'sudo systemctl stop vyos-container-{container}.service')
+            # Export container image for later re-import to new filesystem. We store
+            # the backup on a real disk as a tmpfs (like /tmp) could probably lack
+            # memory if a host has too many containers stored.
+            image_name = config.return_value(base + [container, 'image'])
+            call(f'sudo podman image save --quiet --output /root/{container}.tar --format oci-archive {image_name}')
 
-base_path = '/usr/lib/live/mount/persistence/container/storage'
-for dir in ['libpod', 'vfs', 'vfs-containers', 'vfs-images', 'vfs-layers']:
-    if os.path.exists(f'{base_path}/{dir}'):
-        shutil.rmtree(f'{base_path}/{dir}')
+    # No need to adjust the strage driver online (this is only used for testing and
+    # debugging on a live system) - it is already overlay2 when the migration script
+    # is run during system update. But the specified driver in the image is actually
+    # overwritten by the still present VFS filesystem on disk. Thus podman still
+    # thinks it uses VFS until we delete the libpod directory under:
+    # /usr/lib/live/mount/persistence/container/storage
+    #call('sed -i "s/vfs/overlay2/g" /etc/containers/storage.conf /usr/share/vyos/templates/container/storage.conf.j2')
 
-# Now all remaining information about VFS is gone and we operate in overlayfs2
-# filesystem mode. Time to re-import the images.
-if config.exists(base):
-    for container in config.list_nodes(base):
-        # Export container image for later re-import to new filesystem
-        image_name = config.return_value(base + [container, 'image'])
-        image_path = f'/root/{container}.tar'
-        call(f'sudo podman image load --quiet --input {image_path}')
+    base_path = '/usr/lib/live/mount/persistence/container/storage'
+    for dir in ['libpod', 'vfs', 'vfs-containers', 'vfs-images', 'vfs-layers']:
+        if os.path.exists(f'{base_path}/{dir}'):
+            shutil.rmtree(f'{base_path}/{dir}')
 
-        # Start any given container first
-        call(f'sudo systemctl start vyos-container-{container}.service')
+    # Now all remaining information about VFS is gone and we operate in overlayfs2
+    # filesystem mode. Time to re-import the images.
+    if config.exists(base):
+        for container in config.list_nodes(base):
+            # Export container image for later re-import to new filesystem
+            image_name = config.return_value(base + [container, 'image'])
+            image_path = f'/root/{container}.tar'
+            call(f'sudo podman image load --quiet --input {image_path}')
 
-        # Delete temporary container image
-        if os.path.exists(image_path):
-            os.unlink(image_path)
+            # Start any given container first
+            call(f'sudo systemctl start vyos-container-{container}.service')
 
+            # Delete temporary container image
+            if os.path.exists(image_path):
+                os.unlink(image_path)
diff --git a/src/migration-scripts/container/1-to-2 b/src/migration-scripts/container/1-to-2
old mode 100755
new mode 100644
index 408faf978..c12dd8ebb
--- a/src/migration-scripts/container/1-to-2
+++ b/src/migration-scripts/container/1-to-2
@@ -1,50 +1,32 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T6208: container: rename "cap-add" CLI node to "capability"
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['container', 'name']
-config = ConfigTree(config_file)
 
-# Check if containers exist and we need to perform image manipulation
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
+def migrate(config: ConfigTree) -> None:
 
-for container in config.list_nodes(base):
-    cap_path = base + [container, 'cap-add']
-    if config.exists(cap_path):
-        config.rename(cap_path, 'capability')
+    # Check if containers exist and we need to perform image manipulation
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+    for container in config.list_nodes(base):
+        cap_path = base + [container, 'cap-add']
+        if config.exists(cap_path):
+            config.rename(cap_path, 'capability')
diff --git a/src/migration-scripts/dhcp-relay/1-to-2 b/src/migration-scripts/dhcp-relay/1-to-2
old mode 100755
new mode 100644
index 508bac6be..54cd8d6c0
--- a/src/migration-scripts/dhcp-relay/1-to-2
+++ b/src/migration-scripts/dhcp-relay/1-to-2
@@ -1,35 +1,29 @@
-#!/usr/bin/env python3
+# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Delete "set service dhcp-relay relay-options port" option
 # Delete "set service dhcpv6-relay listen-port" option
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not (config.exists(['service', 'dhcp-relay', 'relay-options', 'port']) or config.exists(['service', 'dhcpv6-relay', 'listen-port'])):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-
-if not (config.exists(['service', 'dhcp-relay', 'relay-options', 'port']) or config.exists(['service', 'dhcpv6-relay', 'listen-port'])):
-    # Nothing to do
-    sys.exit(0)
-else:
     # Delete abandoned node
     config.delete(['service', 'dhcp-relay', 'relay-options', 'port'])
     # Delete abandoned node
     config.delete(['service', 'dhcpv6-relay', 'listen-port'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/dhcp-server/10-to-11 b/src/migration-scripts/dhcp-server/10-to-11
old mode 100755
new mode 100644
index a0dc96ad0..f54a4c7b7
--- a/src/migration-scripts/dhcp-server/10-to-11
+++ b/src/migration-scripts/dhcp-server/10-to-11
@@ -1,48 +1,28 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T6171: rename "service dhcp-server failover" to "service dhcp-server high-availability"
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['service', 'dhcp-server']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
 
-if config.exists(base + ['failover']):
-    config.rename(base + ['failover'],'high-availability')
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
\ No newline at end of file
+    if config.exists(base + ['failover']):
+        config.rename(base + ['failover'],'high-availability')
diff --git a/src/migration-scripts/dhcp-server/4-to-5 b/src/migration-scripts/dhcp-server/4-to-5
old mode 100755
new mode 100644
index d15e0baf5..a655515dc
--- a/src/migration-scripts/dhcp-server/4-to-5
+++ b/src/migration-scripts/dhcp-server/4-to-5
@@ -1,122 +1,118 @@
 #!/usr/bin/env python3
 
+# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
+
 # Removes boolean operator from:
 #   - "set service dhcp-server shared-network-name <xyz> subnet 172.31.0.0/24 ip-forwarding enable (true|false)"
 #   - "set service dhcp-server shared-network-name <xyz> authoritative (true|false)"
 #   - "set service dhcp-server disabled (true|false)"
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
-if not config.exists(['service', 'dhcp-server']):
-    # Nothing to do
-    sys.exit(0)
-else:
-    base = ['service', 'dhcp-server']
-    # Make node "set service dhcp-server dynamic-dns-update enable (true|false)" valueless
-    if config.exists(base + ['dynamic-dns-update']):
-        bool_val = config.return_value(base + ['dynamic-dns-update', 'enable'])
-
-        # Delete the node with the old syntax
-        config.delete(base + ['dynamic-dns-update'])
-        if str(bool_val) == 'true':
-            # Enable dynamic-dns-update with new syntax
-            config.set(base + ['dynamic-dns-update'], value=None)
-
-    # Make node "set service dhcp-server disabled (true|false)" valueless
-    if config.exists(base + ['disabled']):
-        bool_val = config.return_value(base + ['disabled'])
-
-        # Delete the node with the old syntax
-        config.delete(base + ['disabled'])
-        if str(bool_val) == 'true':
-            # Now disable DHCP server with the new syntax
-            config.set(base + ['disable'], value=None)
-
-    # Make node "set service dhcp-server hostfile-update (enable|disable) valueless
-    if config.exists(base + ['hostfile-update']):
-        bool_val = config.return_value(base + ['hostfile-update'])
-
-        # Delete the node with the old syntax incl. all subnodes
-        config.delete(base + ['hostfile-update'])
-        if str(bool_val) == 'enable':
-            # Enable hostfile update with new syntax
-            config.set(base + ['hostfile-update'], value=None)
-
-    # Run this for every instance if 'shared-network-name'
-    for network in config.list_nodes(base + ['shared-network-name']):
-        base_network = base + ['shared-network-name', network]
-        # format as tag node to avoid loading problems
-        config.set_tag(base + ['shared-network-name'])
-
-        # Run this for every specified 'subnet'
-        for subnet in config.list_nodes(base_network + ['subnet']):
-            base_subnet = base_network + ['subnet', subnet]
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(['service', 'dhcp-server']):
+        # Nothing to do
+        return
+    else:
+        base = ['service', 'dhcp-server']
+        # Make node "set service dhcp-server dynamic-dns-update enable (true|false)" valueless
+        if config.exists(base + ['dynamic-dns-update']):
+            bool_val = config.return_value(base + ['dynamic-dns-update', 'enable'])
+
+            # Delete the node with the old syntax
+            config.delete(base + ['dynamic-dns-update'])
+            if str(bool_val) == 'true':
+                # Enable dynamic-dns-update with new syntax
+                config.set(base + ['dynamic-dns-update'], value=None)
+
+        # Make node "set service dhcp-server disabled (true|false)" valueless
+        if config.exists(base + ['disabled']):
+            bool_val = config.return_value(base + ['disabled'])
+
+            # Delete the node with the old syntax
+            config.delete(base + ['disabled'])
+            if str(bool_val) == 'true':
+                # Now disable DHCP server with the new syntax
+                config.set(base + ['disable'], value=None)
+
+        # Make node "set service dhcp-server hostfile-update (enable|disable) valueless
+        if config.exists(base + ['hostfile-update']):
+            bool_val = config.return_value(base + ['hostfile-update'])
+
+            # Delete the node with the old syntax incl. all subnodes
+            config.delete(base + ['hostfile-update'])
+            if str(bool_val) == 'enable':
+                # Enable hostfile update with new syntax
+                config.set(base + ['hostfile-update'], value=None)
+
+        # Run this for every instance if 'shared-network-name'
+        for network in config.list_nodes(base + ['shared-network-name']):
+            base_network = base + ['shared-network-name', network]
             # format as tag node to avoid loading problems
-            config.set_tag(base_network + ['subnet'])
+            config.set_tag(base + ['shared-network-name'])
 
-            # Make node "set service dhcp-server shared-network-name <xyz> subnet 172.31.0.0/24 ip-forwarding enable" valueless
-            if config.exists(base_subnet + ['ip-forwarding', 'enable']):
-                bool_val = config.return_value(base_subnet + ['ip-forwarding', 'enable'])
-                # Delete the node with the old syntax
-                config.delete(base_subnet + ['ip-forwarding'])
-                if str(bool_val) == 'true':
-                    # Recreate node with new syntax
-                    config.set(base_subnet + ['ip-forwarding'], value=None)
-
-            # Rename node "set service dhcp-server shared-network-name <xyz> subnet 172.31.0.0/24 start <172.16.0.4> stop <172.16.0.9>
-            if config.exists(base_subnet + ['start']):
-                # This is the new "range" id for DHCP lease ranges
-                r_id = 0
-                for range in config.list_nodes(base_subnet + ['start']):
-                    range_start = range
-                    range_stop = config.return_value(base_subnet + ['start', range_start, 'stop'])
+            # Run this for every specified 'subnet'
+            for subnet in config.list_nodes(base_network + ['subnet']):
+                base_subnet = base_network + ['subnet', subnet]
+                # format as tag node to avoid loading problems
+                config.set_tag(base_network + ['subnet'])
 
+                # Make node "set service dhcp-server shared-network-name <xyz> subnet 172.31.0.0/24 ip-forwarding enable" valueless
+                if config.exists(base_subnet + ['ip-forwarding', 'enable']):
+                    bool_val = config.return_value(base_subnet + ['ip-forwarding', 'enable'])
                     # Delete the node with the old syntax
-                    config.delete(base_subnet + ['start', range_start])
+                    config.delete(base_subnet + ['ip-forwarding'])
+                    if str(bool_val) == 'true':
+                        # Recreate node with new syntax
+                        config.set(base_subnet + ['ip-forwarding'], value=None)
+
+                # Rename node "set service dhcp-server shared-network-name <xyz> subnet 172.31.0.0/24 start <172.16.0.4> stop <172.16.0.9>
+                if config.exists(base_subnet + ['start']):
+                    # This is the new "range" id for DHCP lease ranges
+                    r_id = 0
+                    for range in config.list_nodes(base_subnet + ['start']):
+                        range_start = range
+                        range_stop = config.return_value(base_subnet + ['start', range_start, 'stop'])
+
+                        # Delete the node with the old syntax
+                        config.delete(base_subnet + ['start', range_start])
+
+                        # Create the node for the new syntax
+                        # Note: range is a tag node, counter is its child, not a value
+                        config.set(base_subnet + ['range', r_id])
+                        config.set(base_subnet + ['range', r_id, 'start'], value=range_start)
+                        config.set(base_subnet + ['range', r_id, 'stop'], value=range_stop)
+
+                        # format as tag node to avoid loading problems
+                        config.set_tag(base_subnet + ['range'])
+
+                        # increment range id for possible next range definition
+                        r_id += 1
 
-                    # Create the node for the new syntax
-                    # Note: range is a tag node, counter is its child, not a value
-                    config.set(base_subnet + ['range', r_id])
-                    config.set(base_subnet + ['range', r_id, 'start'], value=range_start)
-                    config.set(base_subnet + ['range', r_id, 'stop'], value=range_stop)
+                    # Delete the node with the old syntax
+                    config.delete(['service', 'dhcp-server', 'shared-network-name', network, 'subnet', subnet, 'start'])
 
-                    # format as tag node to avoid loading problems
-                    config.set_tag(base_subnet + ['range'])
 
-                    # increment range id for possible next range definition
-                    r_id += 1
+            # Make node "set service dhcp-server shared-network-name <xyz> authoritative" valueless
+            if config.exists(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative']):
+                authoritative = config.return_value(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative'])
 
                 # Delete the node with the old syntax
-                config.delete(['service', 'dhcp-server', 'shared-network-name', network, 'subnet', subnet, 'start'])
+                config.delete(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative'])
 
-
-        # Make node "set service dhcp-server shared-network-name <xyz> authoritative" valueless
-        if config.exists(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative']):
-            authoritative = config.return_value(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative'])
-
-            # Delete the node with the old syntax
-            config.delete(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative'])
-
-            # Recreate node with new syntax - if required
-            if authoritative == "enable":
-                config.set(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
+                # Recreate node with new syntax - if required
+                if authoritative == "enable":
+                    config.set(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative'])
diff --git a/src/migration-scripts/dhcp-server/5-to-6 b/src/migration-scripts/dhcp-server/5-to-6
old mode 100755
new mode 100644
index f5c766a09..9404cd038
--- a/src/migration-scripts/dhcp-server/5-to-6
+++ b/src/migration-scripts/dhcp-server/5-to-6
@@ -1,87 +1,69 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T1968: allow multiple static-routes to be configured
 # T3838: rename dns-server -> name-server
 
-import sys
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['service', 'dhcp-server']
-config = ConfigTree(config_file)
-
-if not config.exists(base + ['shared-network-name']):
-    # Nothing to do
-    exit(0)
-
-# Run this for every instance if 'shared-network-name'
-for network in config.list_nodes(base + ['shared-network-name']):
-    base_network = base + ['shared-network-name', network]
-
-    if not config.exists(base_network + ['subnet']):
-        continue
-
-    # Run this for every specified 'subnet'
-    for subnet in config.list_nodes(base_network + ['subnet']):
-        base_subnet = base_network + ['subnet', subnet]
-
-        # T1968: allow multiple static-routes to be configured
-        if config.exists(base_subnet + ['static-route']):
-            prefix = config.return_value(base_subnet + ['static-route', 'destination-subnet'])
-            router = config.return_value(base_subnet + ['static-route', 'router'])
-            config.delete(base_subnet + ['static-route'])
-
-            config.set(base_subnet + ['static-route', prefix, 'next-hop'], value=router)
-            config.set_tag(base_subnet + ['static-route'])
-
-        # T3838: rename dns-server -> name-server
-        if config.exists(base_subnet + ['dns-server']):
-            config.rename(base_subnet + ['dns-server'], 'name-server')
-
-
-        # T3672: ISC DHCP server only supports one failover peer
-        if config.exists(base_subnet + ['failover']):
-            # There can only be one failover configuration, if none is present
-            # we add the first one
-            if not config.exists(base + ['failover']):
-                local = config.return_value(base_subnet + ['failover', 'local-address'])
-                remote = config.return_value(base_subnet + ['failover', 'peer-address'])
-                status = config.return_value(base_subnet + ['failover', 'status'])
-                name = config.return_value(base_subnet + ['failover', 'name'])
-
-                config.set(base + ['failover', 'remote'], value=remote)
-                config.set(base + ['failover', 'source-address'], value=local)
-                config.set(base + ['failover', 'status'], value=status)
-                config.set(base + ['failover', 'name'], value=name)
-
-            config.delete(base_subnet + ['failover'])
-            config.set(base_subnet + ['enable-failover'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base + ['shared-network-name']):
+        # Nothing to do
+        return
+
+    # Run this for every instance if 'shared-network-name'
+    for network in config.list_nodes(base + ['shared-network-name']):
+        base_network = base + ['shared-network-name', network]
+
+        if not config.exists(base_network + ['subnet']):
+            continue
+
+        # Run this for every specified 'subnet'
+        for subnet in config.list_nodes(base_network + ['subnet']):
+            base_subnet = base_network + ['subnet', subnet]
+
+            # T1968: allow multiple static-routes to be configured
+            if config.exists(base_subnet + ['static-route']):
+                prefix = config.return_value(base_subnet + ['static-route', 'destination-subnet'])
+                router = config.return_value(base_subnet + ['static-route', 'router'])
+                config.delete(base_subnet + ['static-route'])
+
+                config.set(base_subnet + ['static-route', prefix, 'next-hop'], value=router)
+                config.set_tag(base_subnet + ['static-route'])
+
+            # T3838: rename dns-server -> name-server
+            if config.exists(base_subnet + ['dns-server']):
+                config.rename(base_subnet + ['dns-server'], 'name-server')
+
+
+            # T3672: ISC DHCP server only supports one failover peer
+            if config.exists(base_subnet + ['failover']):
+                # There can only be one failover configuration, if none is present
+                # we add the first one
+                if not config.exists(base + ['failover']):
+                    local = config.return_value(base_subnet + ['failover', 'local-address'])
+                    remote = config.return_value(base_subnet + ['failover', 'peer-address'])
+                    status = config.return_value(base_subnet + ['failover', 'status'])
+                    name = config.return_value(base_subnet + ['failover', 'name'])
+
+                    config.set(base + ['failover', 'remote'], value=remote)
+                    config.set(base + ['failover', 'source-address'], value=local)
+                    config.set(base + ['failover', 'status'], value=status)
+                    config.set(base + ['failover', 'name'], value=name)
+
+                config.delete(base_subnet + ['failover'])
+                config.set(base_subnet + ['enable-failover'])
diff --git a/src/migration-scripts/dhcp-server/6-to-7 b/src/migration-scripts/dhcp-server/6-to-7
old mode 100755
new mode 100644
index e6c298a60..4e6583a31
--- a/src/migration-scripts/dhcp-server/6-to-7
+++ b/src/migration-scripts/dhcp-server/6-to-7
@@ -1,76 +1,58 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T6079: Disable duplicate static mappings
 
-import sys
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['service', 'dhcp-server']
-config = ConfigTree(config_file)
-
-if not config.exists(base + ['shared-network-name']):
-    # Nothing to do
-    exit(0)
 
-# Run this for every instance if 'shared-network-name'
-for network in config.list_nodes(base + ['shared-network-name']):
-    base_network = base + ['shared-network-name', network]
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base + ['shared-network-name']):
+        # Nothing to do
+        return
 
-    if not config.exists(base_network + ['subnet']):
-        continue
+    # Run this for every instance if 'shared-network-name'
+    for network in config.list_nodes(base + ['shared-network-name']):
+        base_network = base + ['shared-network-name', network]
 
-    for subnet in config.list_nodes(base_network + ['subnet']):
-        base_subnet = base_network + ['subnet', subnet]
+        if not config.exists(base_network + ['subnet']):
+            continue
 
-        if config.exists(base_subnet + ['static-mapping']):
-            used_mac = []
-            used_ip = []
+        for subnet in config.list_nodes(base_network + ['subnet']):
+            base_subnet = base_network + ['subnet', subnet]
 
-            for mapping in config.list_nodes(base_subnet + ['static-mapping']):
-                base_mapping = base_subnet + ['static-mapping', mapping]
+            if config.exists(base_subnet + ['static-mapping']):
+                used_mac = []
+                used_ip = []
 
-                if config.exists(base_mapping + ['mac-address']):
-                    mac = config.return_value(base_mapping + ['mac-address'])
+                for mapping in config.list_nodes(base_subnet + ['static-mapping']):
+                    base_mapping = base_subnet + ['static-mapping', mapping]
 
-                    if mac in used_mac:
-                        config.set(base_mapping + ['disable'])
-                    else:
-                        used_mac.append(mac)
+                    if config.exists(base_mapping + ['mac-address']):
+                        mac = config.return_value(base_mapping + ['mac-address'])
 
-                if config.exists(base_mapping + ['ip-address']):
-                    ip = config.return_value(base_mapping + ['ip-address'])
+                        if mac in used_mac:
+                            config.set(base_mapping + ['disable'])
+                        else:
+                            used_mac.append(mac)
 
-                    if ip in used_ip:
-                        config.set(base_subnet + ['static-mapping', mapping, 'disable'])
-                    else:
-                        used_ip.append(ip)
+                    if config.exists(base_mapping + ['ip-address']):
+                        ip = config.return_value(base_mapping + ['ip-address'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+                        if ip in used_ip:
+                            config.set(base_subnet + ['static-mapping', mapping, 'disable'])
+                        else:
+                            used_ip.append(ip)
diff --git a/src/migration-scripts/dhcp-server/7-to-8 b/src/migration-scripts/dhcp-server/7-to-8
old mode 100755
new mode 100644
index ccf385a30..7fcb62e86
--- a/src/migration-scripts/dhcp-server/7-to-8
+++ b/src/migration-scripts/dhcp-server/7-to-8
@@ -1,87 +1,69 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3316: Migrate to Kea
 #        - global-parameters will not function
 #        - shared-network-parameters will not function
 #        - subnet-parameters will not function
 #        - static-mapping-parameters will not function
 #        - host-decl-name is on by default, option removed
 #        - ping-check no longer supported
 #        - failover is default enabled on all subnets that exist on failover servers
 
-import sys
 from vyos.configtree import ConfigTree
 
-if (len(sys.argv) < 2):
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['service', 'dhcp-server']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
 
-if config.exists(base + ['host-decl-name']):
-    config.delete(base + ['host-decl-name'])
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-if config.exists(base + ['global-parameters']):
-    config.delete(base + ['global-parameters'])
+    if config.exists(base + ['host-decl-name']):
+        config.delete(base + ['host-decl-name'])
 
-if config.exists(base + ['shared-network-name']):
-    for network in config.list_nodes(base + ['shared-network-name']):
-        base_network = base + ['shared-network-name', network]
+    if config.exists(base + ['global-parameters']):
+        config.delete(base + ['global-parameters'])
 
-        if config.exists(base_network + ['ping-check']):
-            config.delete(base_network + ['ping-check'])
+    if config.exists(base + ['shared-network-name']):
+        for network in config.list_nodes(base + ['shared-network-name']):
+            base_network = base + ['shared-network-name', network]
 
-        if config.exists(base_network + ['shared-network-parameters']):
-            config.delete(base_network +['shared-network-parameters'])
+            if config.exists(base_network + ['ping-check']):
+                config.delete(base_network + ['ping-check'])
 
-        if not config.exists(base_network + ['subnet']):
-            continue
+            if config.exists(base_network + ['shared-network-parameters']):
+                config.delete(base_network +['shared-network-parameters'])
 
-        # Run this for every specified 'subnet'
-        for subnet in config.list_nodes(base_network + ['subnet']):
-            base_subnet = base_network + ['subnet', subnet]
+            if not config.exists(base_network + ['subnet']):
+                continue
 
-            if config.exists(base_subnet + ['enable-failover']):
-                config.delete(base_subnet + ['enable-failover'])
+            # Run this for every specified 'subnet'
+            for subnet in config.list_nodes(base_network + ['subnet']):
+                base_subnet = base_network + ['subnet', subnet]
 
-            if config.exists(base_subnet + ['ping-check']):
-                config.delete(base_subnet + ['ping-check'])
+                if config.exists(base_subnet + ['enable-failover']):
+                    config.delete(base_subnet + ['enable-failover'])
 
-            if config.exists(base_subnet + ['subnet-parameters']):
-                config.delete(base_subnet + ['subnet-parameters'])
+                if config.exists(base_subnet + ['ping-check']):
+                    config.delete(base_subnet + ['ping-check'])
 
-            if config.exists(base_subnet + ['static-mapping']):
-                for mapping in config.list_nodes(base_subnet + ['static-mapping']):
-                    if config.exists(base_subnet + ['static-mapping', mapping, 'static-mapping-parameters']):
-                        config.delete(base_subnet + ['static-mapping', mapping, 'static-mapping-parameters'])
+                if config.exists(base_subnet + ['subnet-parameters']):
+                    config.delete(base_subnet + ['subnet-parameters'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+                if config.exists(base_subnet + ['static-mapping']):
+                    for mapping in config.list_nodes(base_subnet + ['static-mapping']):
+                        if config.exists(base_subnet + ['static-mapping', mapping, 'static-mapping-parameters']):
+                            config.delete(base_subnet + ['static-mapping', mapping, 'static-mapping-parameters'])
diff --git a/src/migration-scripts/dhcp-server/8-to-9 b/src/migration-scripts/dhcp-server/8-to-9
old mode 100755
new mode 100644
index 151aa6d7b..5843e9fda
--- a/src/migration-scripts/dhcp-server/8-to-9
+++ b/src/migration-scripts/dhcp-server/8-to-9
@@ -1,65 +1,47 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3316:
 # - Adjust hostname to have valid FQDN characters only (underscores aren't allowed anymore)
 # - Rename "service dhcp-server shared-network-name ... static-mapping <hostname> mac-address ..."
 #       to "service dhcp-server shared-network-name ... static-mapping <hostname> mac ..."
 
-import sys
 import re
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['service', 'dhcp-server', 'shared-network-name']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
-
-for network in config.list_nodes(base):
-    # Run this for every specified 'subnet'
-    if config.exists(base + [network, 'subnet']):
-        for subnet in config.list_nodes(base + [network, 'subnet']):
-            base_subnet = base + [network, 'subnet', subnet]
-            if config.exists(base_subnet + ['static-mapping']):
-                for hostname in config.list_nodes(base_subnet + ['static-mapping']):
-                    base_mapping = base_subnet + ['static-mapping', hostname]
-
-                    # Rename the 'mac-address' node to 'mac'
-                    if config.exists(base_mapping + ['mac-address']):
-                        config.rename(base_mapping + ['mac-address'], 'mac')
-
-                    # Adjust hostname to have valid FQDN characters only
-                    new_hostname = re.sub(r'[^a-zA-Z0-9-.]', '-', hostname)
-                    if new_hostname != hostname:
-                        config.rename(base_mapping, new_hostname)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    for network in config.list_nodes(base):
+        # Run this for every specified 'subnet'
+        if config.exists(base + [network, 'subnet']):
+            for subnet in config.list_nodes(base + [network, 'subnet']):
+                base_subnet = base + [network, 'subnet', subnet]
+                if config.exists(base_subnet + ['static-mapping']):
+                    for hostname in config.list_nodes(base_subnet + ['static-mapping']):
+                        base_mapping = base_subnet + ['static-mapping', hostname]
+
+                        # Rename the 'mac-address' node to 'mac'
+                        if config.exists(base_mapping + ['mac-address']):
+                            config.rename(base_mapping + ['mac-address'], 'mac')
+
+                        # Adjust hostname to have valid FQDN characters only
+                        new_hostname = re.sub(r'[^a-zA-Z0-9-.]', '-', hostname)
+                        if new_hostname != hostname:
+                            config.rename(base_mapping, new_hostname)
diff --git a/src/migration-scripts/dhcp-server/9-to-10 b/src/migration-scripts/dhcp-server/9-to-10
old mode 100755
new mode 100644
index a459b65b5..eda97550d
--- a/src/migration-scripts/dhcp-server/9-to-10
+++ b/src/migration-scripts/dhcp-server/9-to-10
@@ -1,74 +1,57 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3316:
 # - Migrate dhcp options under new option node
 # - Add subnet IDs to existing subnets
 
-import sys
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['service', 'dhcp-server', 'shared-network-name']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
 
 option_nodes = ['bootfile-name', 'bootfile-server', 'bootfile-size', 'captive-portal',
                 'client-prefix-length', 'default-router', 'domain-name', 'domain-search',
                 'name-server', 'ip-forwarding', 'ipv6-only-preferred', 'ntp-server',
                 'pop-server', 'server-identifier', 'smtp-server', 'static-route',
                 'tftp-server-name', 'time-offset', 'time-server', 'time-zone',
                 'vendor-option', 'wins-server', 'wpad-url']
 
-subnet_id = 1
 
-for network in config.list_nodes(base):
-    for option in option_nodes:
-        if config.exists(base + [network, option]):
-            config.set(base + [network, 'option'])
-            config.copy(base + [network, option], base + [network, 'option', option])
-            config.delete(base + [network, option])
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    subnet_id = 1
 
-    if config.exists(base + [network, 'subnet']):
-        for subnet in config.list_nodes(base + [network, 'subnet']):
-            base_subnet = base + [network, 'subnet', subnet]
+    for network in config.list_nodes(base):
+        for option in option_nodes:
+            if config.exists(base + [network, option]):
+                config.set(base + [network, 'option'])
+                config.copy(base + [network, option], base + [network, 'option', option])
+                config.delete(base + [network, option])
 
-            for option in option_nodes:
-                if config.exists(base_subnet + [option]):
-                    config.set(base_subnet + ['option'])
-                    config.copy(base_subnet + [option], base_subnet + ['option', option])
-                    config.delete(base_subnet + [option])
+        if config.exists(base + [network, 'subnet']):
+            for subnet in config.list_nodes(base + [network, 'subnet']):
+                base_subnet = base + [network, 'subnet', subnet]
 
-            config.set(base_subnet + ['subnet-id'], value=subnet_id)
-            subnet_id += 1
+                for option in option_nodes:
+                    if config.exists(base_subnet + [option]):
+                        config.set(base_subnet + ['option'])
+                        config.copy(base_subnet + [option], base_subnet + ['option', option])
+                        config.delete(base_subnet + [option])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+                config.set(base_subnet + ['subnet-id'], value=subnet_id)
+                subnet_id += 1
diff --git a/src/migration-scripts/dhcpv6-server/0-to-1 b/src/migration-scripts/dhcpv6-server/0-to-1
old mode 100755
new mode 100644
index deae1ca29..fd9b2d739
--- a/src/migration-scripts/dhcpv6-server/0-to-1
+++ b/src/migration-scripts/dhcpv6-server/0-to-1
@@ -1,61 +1,44 @@
-#!/usr/bin/env python3
+# Copyright 202-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # combine both sip-server-address and sip-server-name nodes to common sip-server
 
-from sys import argv, exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
+base = ['service', 'dhcpv6-server', 'shared-network-name']
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-base = ['service', 'dhcpv6-server', 'shared-network-name']
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-else:
     # we need to run this for every configured network
     for network in config.list_nodes(base):
         for subnet in config.list_nodes(base + [network, 'subnet']):
             sip_server = []
 
             # Do we have 'sip-server-address' configured?
             if config.exists(base + [network, 'subnet', subnet, 'sip-server-address']):
                 sip_server += config.return_values(base + [network, 'subnet', subnet, 'sip-server-address'])
                 config.delete(base + [network, 'subnet', subnet, 'sip-server-address'])
 
             # Do we have 'sip-server-name' configured?
             if config.exists(base + [network, 'subnet', subnet, 'sip-server-name']):
                 sip_server += config.return_values(base + [network, 'subnet', subnet, 'sip-server-name'])
                 config.delete(base + [network, 'subnet', subnet, 'sip-server-name'])
 
             # Write new CLI value for sip-server
             for server in sip_server:
                 config.set(base + [network, 'subnet', subnet, 'sip-server'], value=server, replace=False)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/dhcpv6-server/1-to-2 b/src/migration-scripts/dhcpv6-server/1-to-2
old mode 100755
new mode 100644
index cc5a8900a..ad307495c
--- a/src/migration-scripts/dhcpv6-server/1-to-2
+++ b/src/migration-scripts/dhcpv6-server/1-to-2
@@ -1,86 +1,68 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3316: Migrate to Kea
 # - Kea was meant to have support for key "prefix-highest" under PD which would allow an address range
 #   However this seems to have never been implemented. A conversion to prefix length is needed (where possible).
 #   Ref: https://lists.isc.org/pipermail/kea-users/2022-November/003686.html
 # - Remove prefix temporary value, convert to multi leafNode (https://kea.readthedocs.io/en/kea-2.2.0/arm/dhcp6-srv.html#dhcpv6-server-limitations)
 
-import sys
 from vyos.configtree import ConfigTree
 from vyos.utils.network import ipv6_prefix_length
 
-if (len(sys.argv) < 1):
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['service', 'dhcpv6-server', 'shared-network-name']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
 
-for network in config.list_nodes(base):
-    if not config.exists(base + [network, 'subnet']):
-        continue
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-    for subnet in config.list_nodes(base + [network, 'subnet']):
-        # Delete temporary value under address-range prefix, convert tagNode to leafNode multi
-        if config.exists(base + [network, 'subnet', subnet, 'address-range', 'prefix']):
-            prefix_base = base + [network, 'subnet', subnet, 'address-range', 'prefix']
-            prefixes = config.list_nodes(prefix_base)
-            
-            config.delete(prefix_base)
+    for network in config.list_nodes(base):
+        if not config.exists(base + [network, 'subnet']):
+            continue
 
-            for prefix in prefixes:
-                config.set(prefix_base, value=prefix, replace=False)
+        for subnet in config.list_nodes(base + [network, 'subnet']):
+            # Delete temporary value under address-range prefix, convert tagNode to leafNode multi
+            if config.exists(base + [network, 'subnet', subnet, 'address-range', 'prefix']):
+                prefix_base = base + [network, 'subnet', subnet, 'address-range', 'prefix']
+                prefixes = config.list_nodes(prefix_base)
+                
+                config.delete(prefix_base)
 
-        if config.exists(base + [network, 'subnet', subnet, 'prefix-delegation', 'prefix']):
-            prefix_base = base + [network, 'subnet', subnet, 'prefix-delegation', 'prefix']
+                for prefix in prefixes:
+                    config.set(prefix_base, value=prefix, replace=False)
 
-            config.set(prefix_base)
-            config.set_tag(prefix_base)
+            if config.exists(base + [network, 'subnet', subnet, 'prefix-delegation', 'prefix']):
+                prefix_base = base + [network, 'subnet', subnet, 'prefix-delegation', 'prefix']
 
-            for start in config.list_nodes(base + [network, 'subnet', subnet, 'prefix-delegation', 'start']):
-                path = base + [network, 'subnet', subnet, 'prefix-delegation', 'start', start]
+                config.set(prefix_base)
+                config.set_tag(prefix_base)
 
-                delegated_length = config.return_value(path + ['prefix-length'])
-                stop = config.return_value(path + ['stop'])
+                for start in config.list_nodes(base + [network, 'subnet', subnet, 'prefix-delegation', 'start']):
+                    path = base + [network, 'subnet', subnet, 'prefix-delegation', 'start', start]
 
-                prefix_length = ipv6_prefix_length(start, stop)
+                    delegated_length = config.return_value(path + ['prefix-length'])
+                    stop = config.return_value(path + ['stop'])
 
-                # This range could not be converted into a simple prefix length and must be skipped
-                if not prefix_length:
-                    continue
+                    prefix_length = ipv6_prefix_length(start, stop)
 
-                config.set(prefix_base + [start, 'delegated-length'], value=delegated_length)
-                config.set(prefix_base + [start, 'prefix-length'], value=prefix_length)
+                    # This range could not be converted into a simple prefix length and must be skipped
+                    if not prefix_length:
+                        continue
 
-            config.delete(base + [network, 'subnet', subnet, 'prefix-delegation', 'start'])
+                    config.set(prefix_base + [start, 'delegated-length'], value=delegated_length)
+                    config.set(prefix_base + [start, 'prefix-length'], value=prefix_length)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+                config.delete(base + [network, 'subnet', subnet, 'prefix-delegation', 'start'])
diff --git a/src/migration-scripts/dhcpv6-server/2-to-3 b/src/migration-scripts/dhcpv6-server/2-to-3
old mode 100755
new mode 100644
index f4bdc1d1e..b44798d18
--- a/src/migration-scripts/dhcpv6-server/2-to-3
+++ b/src/migration-scripts/dhcpv6-server/2-to-3
@@ -1,78 +1,60 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3316:
 # - Adjust hostname to have valid FQDN characters only (underscores aren't allowed anymore)
 # - Adjust duid (old identifier) to comply with duid format
 # - Rename "service dhcpv6-server shared-network-name ... static-mapping <hostname> identifier ..."
 #       to "service dhcpv6-server shared-network-name ... static-mapping <hostname> duid ..."
 # - Rename "service dhcpv6-server shared-network-name ... static-mapping <hostname> mac-address ..."
 #       to "service dhcpv6-server shared-network-name ... static-mapping <hostname> mac ..."
 
-import sys
 import re
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['service', 'dhcpv6-server', 'shared-network-name']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
-
-for network in config.list_nodes(base):
-    # Run this for every specified 'subnet'
-    if config.exists(base + [network, 'subnet']):
-        for subnet in config.list_nodes(base + [network, 'subnet']):
-            base_subnet = base + [network, 'subnet', subnet]
-            if config.exists(base_subnet + ['static-mapping']):
-                for hostname in config.list_nodes(base_subnet + ['static-mapping']):
-                    base_mapping = base_subnet + ['static-mapping', hostname]
-                    if config.exists(base_mapping + ['identifier']):
-
-                        # Adjust duid to comply with duid format (a:3:b:04:... => 0a:03:0b:04:...)
-                        duid = config.return_value(base_mapping + ['identifier'])
-                        new_duid = ':'.join(x.rjust(2,'0') for x in duid.split(':'))
-                        if new_duid != duid:
-                            config.set(base_mapping + ['identifier'], new_duid)
-
-                        # Rename the 'identifier' node to 'duid'
-                        config.rename(base_mapping + ['identifier'], 'duid')
-
-                    # Rename the 'mac-address' node to 'mac'
-                    if config.exists(base_mapping + ['mac-address']):
-                        config.rename(base_mapping + ['mac-address'], 'mac')
-
-                    # Adjust hostname to have valid FQDN characters only
-                    new_hostname = re.sub(r'[^a-zA-Z0-9-.]', '-', hostname)
-                    if new_hostname != hostname:
-                        config.rename(base_mapping, new_hostname)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    for network in config.list_nodes(base):
+        # Run this for every specified 'subnet'
+        if config.exists(base + [network, 'subnet']):
+            for subnet in config.list_nodes(base + [network, 'subnet']):
+                base_subnet = base + [network, 'subnet', subnet]
+                if config.exists(base_subnet + ['static-mapping']):
+                    for hostname in config.list_nodes(base_subnet + ['static-mapping']):
+                        base_mapping = base_subnet + ['static-mapping', hostname]
+                        if config.exists(base_mapping + ['identifier']):
+
+                            # Adjust duid to comply with duid format (a:3:b:04:... => 0a:03:0b:04:...)
+                            duid = config.return_value(base_mapping + ['identifier'])
+                            new_duid = ':'.join(x.rjust(2,'0') for x in duid.split(':'))
+                            if new_duid != duid:
+                                config.set(base_mapping + ['identifier'], new_duid)
+
+                            # Rename the 'identifier' node to 'duid'
+                            config.rename(base_mapping + ['identifier'], 'duid')
+
+                        # Rename the 'mac-address' node to 'mac'
+                        if config.exists(base_mapping + ['mac-address']):
+                            config.rename(base_mapping + ['mac-address'], 'mac')
+
+                        # Adjust hostname to have valid FQDN characters only
+                        new_hostname = re.sub(r'[^a-zA-Z0-9-.]', '-', hostname)
+                        if new_hostname != hostname:
+                            config.rename(base_mapping, new_hostname)
diff --git a/src/migration-scripts/dhcpv6-server/3-to-4 b/src/migration-scripts/dhcpv6-server/3-to-4
old mode 100755
new mode 100644
index 7efc492a5..e38e36505
--- a/src/migration-scripts/dhcpv6-server/3-to-4
+++ b/src/migration-scripts/dhcpv6-server/3-to-4
@@ -1,89 +1,72 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3316:
 # - Add subnet IDs to existing subnets
 # - Move options to option node
 # - Migrate address-range to range tagNode
 
-import sys
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['service', 'dhcpv6-server', 'shared-network-name']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
 
 option_nodes = ['captive-portal', 'domain-search', 'name-server',
                 'nis-domain', 'nis-server', 'nisplus-domain', 'nisplus-server',
                 'sip-server', 'sntp-server', 'vendor-option']
 
-subnet_id = 1
 
-for network in config.list_nodes(base):
-    if config.exists(base + [network, 'subnet']):
-        for subnet in config.list_nodes(base + [network, 'subnet']):
-            base_subnet = base + [network, 'subnet', subnet]
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    subnet_id = 1
 
-            if config.exists(base_subnet + ['address-range']):
-                config.set(base_subnet + ['range'])
-                config.set_tag(base_subnet + ['range'])
+    for network in config.list_nodes(base):
+        if config.exists(base + [network, 'subnet']):
+            for subnet in config.list_nodes(base + [network, 'subnet']):
+                base_subnet = base + [network, 'subnet', subnet]
 
-                range_id = 1
+                if config.exists(base_subnet + ['address-range']):
+                    config.set(base_subnet + ['range'])
+                    config.set_tag(base_subnet + ['range'])
 
-                if config.exists(base_subnet + ['address-range', 'prefix']):
-                    for prefix in config.return_values(base_subnet + ['address-range', 'prefix']):
-                        config.set(base_subnet + ['range', range_id, 'prefix'], value=prefix)
+                    range_id = 1
 
-                        range_id += 1
+                    if config.exists(base_subnet + ['address-range', 'prefix']):
+                        for prefix in config.return_values(base_subnet + ['address-range', 'prefix']):
+                            config.set(base_subnet + ['range', range_id, 'prefix'], value=prefix)
 
-                if config.exists(base_subnet + ['address-range', 'start']):
-                    for start in config.list_nodes(base_subnet + ['address-range', 'start']):
-                        stop = config.return_value(base_subnet + ['address-range', 'start', start, 'stop'])
+                            range_id += 1
 
-                        config.set(base_subnet + ['range', range_id, 'start'], value=start)
-                        config.set(base_subnet + ['range', range_id, 'stop'], value=stop)
+                    if config.exists(base_subnet + ['address-range', 'start']):
+                        for start in config.list_nodes(base_subnet + ['address-range', 'start']):
+                            stop = config.return_value(base_subnet + ['address-range', 'start', start, 'stop'])
 
-                        range_id += 1
+                            config.set(base_subnet + ['range', range_id, 'start'], value=start)
+                            config.set(base_subnet + ['range', range_id, 'stop'], value=stop)
 
-                config.delete(base_subnet + ['address-range'])
+                            range_id += 1
 
-            for option in option_nodes:
-                if config.exists(base_subnet + [option]):
-                    config.set(base_subnet + ['option'])
-                    config.copy(base_subnet + [option], base_subnet + ['option', option])
-                    config.delete(base_subnet + [option])
+                    config.delete(base_subnet + ['address-range'])
 
-            config.set(base_subnet + ['subnet-id'], value=subnet_id)
-            subnet_id += 1
+                for option in option_nodes:
+                    if config.exists(base_subnet + [option]):
+                        config.set(base_subnet + ['option'])
+                        config.copy(base_subnet + [option], base_subnet + ['option', option])
+                        config.delete(base_subnet + [option])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+                config.set(base_subnet + ['subnet-id'], value=subnet_id)
+                subnet_id += 1
diff --git a/src/migration-scripts/dhcpv6-server/4-to-5 b/src/migration-scripts/dhcpv6-server/4-to-5
old mode 100755
new mode 100644
index 55fda91b3..ad18e1a84
--- a/src/migration-scripts/dhcpv6-server/4-to-5
+++ b/src/migration-scripts/dhcpv6-server/4-to-5
@@ -1,91 +1,73 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5993: Check if subnet is locally accessible and assign interface to subnet
 
-import sys
 from ipaddress import ip_network
 from vyos.configtree import ConfigTree
 
-if (len(sys.argv) < 1):
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['service', 'dhcpv6-server', 'shared-network-name']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-def find_subnet_interface(subnet):
-    subnet_net = ip_network(subnet)
-
-    def check_addr(if_path):
-        if config.exists(if_path + ['address']):
-            for addr in config.return_values(if_path + ['address']):
-                try:
-                    if ip_network(addr, strict=False) == subnet_net:
-                        return True
-                except:
-                    pass # interface address was probably "dhcp" or other magic string
-        return None
-
-    for iftype in config.list_nodes(['interfaces']):
-        for ifname in config.list_nodes(['interfaces', iftype]):
-            if_base = ['interfaces', iftype, ifname]
-
-            if check_addr(if_base):
-                return ifname
-
-            if config.exists(if_base + ['vif']):
-                for vif in config.list_nodes(if_base + ['vif']):
-                    if check_addr(if_base + ['vif', vif]):
-                        return f'{ifname}.{vif}'
-
-            if config.exists(if_base + ['vif-s']):
-                for vifs in config.list_nodes(if_base + ['vif-s']):
-                    if check_addr(if_base + ['vif-s', vifs]):
-                        return f'{ifname}.{vifs}'
-
-                    if config.exists(if_base + ['vif-s', vifs, 'vif-c']):
-                        for vifc in config.list_nodes(if_base + ['vif-s', vifs, 'vif-c']):
-                            if check_addr(if_base + ['vif-s', vifs, 'vif-c', vifc]):
-                                return f'{ifname}.{vifs}.{vifc}'
-
-    return False
-
-for network in config.list_nodes(base):
-    if not config.exists(base + [network, 'subnet']):
-        continue
-
-    for subnet in config.list_nodes(base + [network, 'subnet']):
-        subnet_interface = find_subnet_interface(subnet)
-
-        if subnet_interface:
-            config.set(base + [network, 'subnet', subnet, 'interface'], value=subnet_interface)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    def find_subnet_interface(subnet):
+        subnet_net = ip_network(subnet)
+
+        def check_addr(if_path):
+            if config.exists(if_path + ['address']):
+                for addr in config.return_values(if_path + ['address']):
+                    try:
+                        if ip_network(addr, strict=False) == subnet_net:
+                            return True
+                    except:
+                        pass # interface address was probably "dhcp" or other magic string
+            return None
+
+        for iftype in config.list_nodes(['interfaces']):
+            for ifname in config.list_nodes(['interfaces', iftype]):
+                if_base = ['interfaces', iftype, ifname]
+
+                if check_addr(if_base):
+                    return ifname
+
+                if config.exists(if_base + ['vif']):
+                    for vif in config.list_nodes(if_base + ['vif']):
+                        if check_addr(if_base + ['vif', vif]):
+                            return f'{ifname}.{vif}'
+
+                if config.exists(if_base + ['vif-s']):
+                    for vifs in config.list_nodes(if_base + ['vif-s']):
+                        if check_addr(if_base + ['vif-s', vifs]):
+                            return f'{ifname}.{vifs}'
+
+                        if config.exists(if_base + ['vif-s', vifs, 'vif-c']):
+                            for vifc in config.list_nodes(if_base + ['vif-s', vifs, 'vif-c']):
+                                if check_addr(if_base + ['vif-s', vifs, 'vif-c', vifc]):
+                                    return f'{ifname}.{vifs}.{vifc}'
+
+        return False
+
+    for network in config.list_nodes(base):
+        if not config.exists(base + [network, 'subnet']):
+            continue
+
+        for subnet in config.list_nodes(base + [network, 'subnet']):
+            subnet_interface = find_subnet_interface(subnet)
+
+            if subnet_interface:
+                config.set(base + [network, 'subnet', subnet, 'interface'], value=subnet_interface)
diff --git a/src/migration-scripts/dns-dynamic/0-to-1 b/src/migration-scripts/dns-dynamic/0-to-1
old mode 100755
new mode 100644
index b7674a9c8..6a91b36af
--- a/src/migration-scripts/dns-dynamic/0-to-1
+++ b/src/migration-scripts/dns-dynamic/0-to-1
@@ -1,128 +1,109 @@
-#!/usr/bin/env python3
-
-# Copyright (C) 2023 VyOS maintainers and contributors
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5144:
 # - migrate "service dns dynamic interface ..."
 #        to "service dns dynamic address ..."
 # - migrate "service dns dynamic interface <interface> use-web ..."
 #        to "service dns dynamic address <address> web-options ..."
 # - migrate "service dns dynamic interface <interface> rfc2136 <config> record ..."
 #        to "service dns dynamic address <address> rfc2136 <config> host-name ..."
 # - migrate "service dns dynamic interface <interface> service <config> login ..."
 #        to "service dns dynamic address <address> service <config> username ..."
 # - apply global 'ipv6-enable' to per <config> 'ip-version: ipv6'
 # - apply service protocol mapping upfront, they are not 'auto-detected' anymore
 # - migrate web-options url to stricter format
 
-import sys
 import re
 from vyos.configtree import ConfigTree
 
 service_protocol_mapping = {
     'afraid': 'freedns',
     'changeip': 'changeip',
     'cloudflare': 'cloudflare',
     'dnspark': 'dnspark',
     'dslreports': 'dslreports1',
     'dyndns': 'dyndns2',
     'easydns': 'easydns',
     'namecheap': 'namecheap',
     'noip': 'noip',
     'sitelutions': 'sitelutions',
     'zoneedit': 'zoneedit1'
 }
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 old_base_path = ['service', 'dns', 'dynamic', 'interface']
 new_base_path = ['service', 'dns', 'dynamic', 'address']
 
-if not config.exists(old_base_path):
-    # Nothing to do
-    sys.exit(0)
-
-# Migrate "service dns dynamic interface"
-#      to "service dns dynamic address"
-config.rename(old_base_path, new_base_path[-1])
-
-for address in config.list_nodes(new_base_path):
-    # Migrate "service dns dynamic interface <interface> rfc2136 <config> record"
-    #      to "service dns dynamic address <address> rfc2136 <config> host-name"
-    if config.exists(new_base_path + [address, 'rfc2136']):
-        for rfc_cfg in config.list_nodes(new_base_path + [address, 'rfc2136']):
-            if config.exists(new_base_path + [address, 'rfc2136', rfc_cfg, 'record']):
-                config.rename(new_base_path + [address, 'rfc2136', rfc_cfg, 'record'], 'host-name')
-
-    # Migrate "service dns dynamic interface <interface> service <config> login"
-    #      to "service dns dynamic address <address> service <config> username"
-    if config.exists(new_base_path + [address, 'service']):
-        for svc_cfg in config.list_nodes(new_base_path + [address, 'service']):
-            if config.exists(new_base_path + [address, 'service', svc_cfg, 'login']):
-                config.rename(new_base_path + [address, 'service', svc_cfg, 'login'], 'username')
-            # Apply global 'ipv6-enable' to per <config> 'ip-version: ipv6'
-            if config.exists(new_base_path + [address, 'ipv6-enable']):
-                config.set(new_base_path + [address, 'service', svc_cfg, 'ip-version'], 'ipv6')
-                config.delete(new_base_path + [address, 'ipv6-enable'])
-            # Apply service protocol mapping upfront, they are not 'auto-detected' anymore
-            if svc_cfg in service_protocol_mapping:
-                config.set(new_base_path + [address, 'service', svc_cfg, 'protocol'],
-                           service_protocol_mapping.get(svc_cfg))
-
-    # If use-web is set, then:
-    #   Move "service dns dynamic address <address> <service|rfc2136> <service> ..."
-    #     to "service dns dynamic address web <service|rfc2136> <service>-<address> ..."
-    #   Move "service dns dynamic address web use-web ..."
-    #     to "service dns dynamic address web web-options ..."
-    # Note: The config is named <service>-<address> to avoid name conflict with old entries
-    if config.exists(new_base_path + [address, 'use-web']):
-        for svc_type in ['rfc2136', 'service']:
-            if config.exists(new_base_path + [address, svc_type]):
-                config.set(new_base_path + ['web', svc_type])
-                config.set_tag(new_base_path + ['web', svc_type])
-                for svc_cfg in config.list_nodes(new_base_path + [address, svc_type]):
-                    config.copy(new_base_path + [address, svc_type, svc_cfg],
-                                new_base_path + ['web', svc_type, f'{svc_cfg}-{address}'])
-
-        # Multiple web-options were not supported, so copy only the first one
-        # Also, migrate web-options url to stricter format and transition
-        # checkip.dyndns.org to https://domains.google.com/checkip for better
-        # TLS support (see: https://github.com/ddclient/ddclient/issues/597)
-        if not config.exists(new_base_path + ['web', 'web-options']):
-            config.copy(new_base_path + [address, 'use-web'], new_base_path + ['web', 'web-options'])
-            if config.exists(new_base_path + ['web', 'web-options', 'url']):
-                url = config.return_value(new_base_path + ['web', 'web-options', 'url'])
-                if re.search("^(https?://)?checkip\.dyndns\.org", url):
-                    config.set(new_base_path + ['web', 'web-options', 'url'], 'https://domains.google.com/checkip')
-                if not url.startswith(('http://', 'https://')):
-                    config.set(new_base_path + ['web', 'web-options', 'url'], f'https://{url}')
-
-        config.delete(new_base_path + [address])
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(old_base_path):
+        # Nothing to do
+        return
+
+    # Migrate "service dns dynamic interface"
+    #      to "service dns dynamic address"
+    config.rename(old_base_path, new_base_path[-1])
+
+    for address in config.list_nodes(new_base_path):
+        # Migrate "service dns dynamic interface <interface> rfc2136 <config> record"
+        #      to "service dns dynamic address <address> rfc2136 <config> host-name"
+        if config.exists(new_base_path + [address, 'rfc2136']):
+            for rfc_cfg in config.list_nodes(new_base_path + [address, 'rfc2136']):
+                if config.exists(new_base_path + [address, 'rfc2136', rfc_cfg, 'record']):
+                    config.rename(new_base_path + [address, 'rfc2136', rfc_cfg, 'record'], 'host-name')
+
+        # Migrate "service dns dynamic interface <interface> service <config> login"
+        #      to "service dns dynamic address <address> service <config> username"
+        if config.exists(new_base_path + [address, 'service']):
+            for svc_cfg in config.list_nodes(new_base_path + [address, 'service']):
+                if config.exists(new_base_path + [address, 'service', svc_cfg, 'login']):
+                    config.rename(new_base_path + [address, 'service', svc_cfg, 'login'], 'username')
+                # Apply global 'ipv6-enable' to per <config> 'ip-version: ipv6'
+                if config.exists(new_base_path + [address, 'ipv6-enable']):
+                    config.set(new_base_path + [address, 'service', svc_cfg, 'ip-version'], 'ipv6')
+                    config.delete(new_base_path + [address, 'ipv6-enable'])
+                # Apply service protocol mapping upfront, they are not 'auto-detected' anymore
+                if svc_cfg in service_protocol_mapping:
+                    config.set(new_base_path + [address, 'service', svc_cfg, 'protocol'],
+                               service_protocol_mapping.get(svc_cfg))
+
+        # If use-web is set, then:
+        #   Move "service dns dynamic address <address> <service|rfc2136> <service> ..."
+        #     to "service dns dynamic address web <service|rfc2136> <service>-<address> ..."
+        #   Move "service dns dynamic address web use-web ..."
+        #     to "service dns dynamic address web web-options ..."
+        # Note: The config is named <service>-<address> to avoid name conflict with old entries
+        if config.exists(new_base_path + [address, 'use-web']):
+            for svc_type in ['rfc2136', 'service']:
+                if config.exists(new_base_path + [address, svc_type]):
+                    config.set(new_base_path + ['web', svc_type])
+                    config.set_tag(new_base_path + ['web', svc_type])
+                    for svc_cfg in config.list_nodes(new_base_path + [address, svc_type]):
+                        config.copy(new_base_path + [address, svc_type, svc_cfg],
+                                    new_base_path + ['web', svc_type, f'{svc_cfg}-{address}'])
+
+            # Multiple web-options were not supported, so copy only the first one
+            # Also, migrate web-options url to stricter format and transition
+            # checkip.dyndns.org to https://domains.google.com/checkip for better
+            # TLS support (see: https://github.com/ddclient/ddclient/issues/597)
+            if not config.exists(new_base_path + ['web', 'web-options']):
+                config.copy(new_base_path + [address, 'use-web'], new_base_path + ['web', 'web-options'])
+                if config.exists(new_base_path + ['web', 'web-options', 'url']):
+                    url = config.return_value(new_base_path + ['web', 'web-options', 'url'])
+                    if re.search("^(https?://)?checkip\.dyndns\.org", url):
+                        config.set(new_base_path + ['web', 'web-options', 'url'], 'https://domains.google.com/checkip')
+                    if not url.startswith(('http://', 'https://')):
+                        config.set(new_base_path + ['web', 'web-options', 'url'], f'https://{url}')
+
+            config.delete(new_base_path + [address])
diff --git a/src/migration-scripts/dns-dynamic/1-to-2 b/src/migration-scripts/dns-dynamic/1-to-2
old mode 100755
new mode 100644
index 8b599b57a..5dca9e32f
--- a/src/migration-scripts/dns-dynamic/1-to-2
+++ b/src/migration-scripts/dns-dynamic/1-to-2
@@ -1,70 +1,51 @@
-#!/usr/bin/env python3
-
-# Copyright (C) 2023 VyOS maintainers and contributors
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5708:
 # - migrate "service dns dynamic timeout ..."
 #        to "service dns dynamic interval ..."
 # - remove "service dns dynamic address <interface> web-options ..." when <interface> != "web"
 # - migrate "service dns dynamic address <interface> service <service> protocol dnsexit"
 #        to "service dns dynamic address <interface> service <service> protocol dnsexit2"
 
-import sys
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 base_path = ['service', 'dns', 'dynamic']
 timeout_path = base_path + ['timeout']
 address_path = base_path + ['address']
 
-if not config.exists(base_path):
-    # Nothing to do
-    sys.exit(0)
-
-# Migrate "service dns dynamic timeout ..."
-#      to "service dns dynamic interval ..."
-if config.exists(timeout_path):
-    config.rename(timeout_path, 'interval')
-
-# Remove "service dns dynamic address <interface> web-options ..." when <interface> != "web"
-for address in config.list_nodes(address_path):
-    if config.exists(address_path + [address, 'web-options']) and address != 'web':
-        config.delete(address_path + [address, 'web-options'])
-
-# Migrate "service dns dynamic address <interface> service <service> protocol dnsexit"
-#      to "service dns dynamic address <interface> service <service> protocol dnsexit2"
-for address in config.list_nodes(address_path):
-    for svc_cfg in config.list_nodes(address_path + [address, 'service']):
-        if config.exists(address_path + [address, 'service', svc_cfg, 'protocol']):
-            protocol = config.return_value(address_path + [address, 'service', svc_cfg, 'protocol'])
-            if protocol == 'dnsexit':
-                config.set(address_path + [address, 'service', svc_cfg, 'protocol'], 'dnsexit2')
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base_path):
+        # Nothing to do
+        return
+
+    # Migrate "service dns dynamic timeout ..."
+    #      to "service dns dynamic interval ..."
+    if config.exists(timeout_path):
+        config.rename(timeout_path, 'interval')
+
+    # Remove "service dns dynamic address <interface> web-options ..." when <interface> != "web"
+    for address in config.list_nodes(address_path):
+        if config.exists(address_path + [address, 'web-options']) and address != 'web':
+            config.delete(address_path + [address, 'web-options'])
+
+    # Migrate "service dns dynamic address <interface> service <service> protocol dnsexit"
+    #      to "service dns dynamic address <interface> service <service> protocol dnsexit2"
+    for address in config.list_nodes(address_path):
+        for svc_cfg in config.list_nodes(address_path + [address, 'service']):
+            if config.exists(address_path + [address, 'service', svc_cfg, 'protocol']):
+                protocol = config.return_value(address_path + [address, 'service', svc_cfg, 'protocol'])
+                if protocol == 'dnsexit':
+                    config.set(address_path + [address, 'service', svc_cfg, 'protocol'], 'dnsexit2')
diff --git a/src/migration-scripts/dns-dynamic/2-to-3 b/src/migration-scripts/dns-dynamic/2-to-3
old mode 100755
new mode 100644
index 4e0aa37d5..9aafc41a4
--- a/src/migration-scripts/dns-dynamic/2-to-3
+++ b/src/migration-scripts/dns-dynamic/2-to-3
@@ -1,119 +1,99 @@
-#!/usr/bin/env python3
-
-# Copyright (C) 2023 VyOS maintainers and contributors
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5791:
 # - migrate "service dns dynamic address web web-options ..."
 #        to "service dns dynamic name <service> address web ..." (per service)
 # - migrate "service dns dynamic address <address> rfc2136 <service> ..."
 #        to "service dns dynamic name <service> address <interface> protocol 'nsupdate'"
 # - migrate "service dns dynamic address <interface> service <service> ..."
 #        to "service dns dynamic name <service> address <interface> ..."
 # - normalize the all service names to conform with name constraints
 
-import sys
 import re
 from unicodedata import normalize
 from vyos.configtree import ConfigTree
 
 def normalize_name(name):
     """Normalize service names to conform with name constraints.
 
     This is necessary as part of migration because there were no constraints in
     the old name format.
     """
     # Normalize unicode characters to ASCII (NFKD)
     # Replace all separators with hypens, strip leading and trailing hyphens
     name = normalize('NFKD', name).encode('ascii', 'ignore').decode()
     name = re.sub(r'(\s|_|\W)+', '-', name).strip('-')
 
     return name
 
-
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 base_path = ['service', 'dns', 'dynamic']
 address_path = base_path + ['address']
 name_path = base_path + ['name']
 
-if not config.exists(address_path):
-    # Nothing to do
-    sys.exit(0)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(address_path):
+        # Nothing to do
+        return
 
-# config.copy does not recursively create a path, so initialize the name path as tagged node
-if not config.exists(name_path):
-    config.set(name_path)
-    config.set_tag(name_path)
+    # config.copy does not recursively create a path, so initialize the name path as tagged node
+    if not config.exists(name_path):
+        config.set(name_path)
+        config.set_tag(name_path)
 
-for address in config.list_nodes(address_path):
+    for address in config.list_nodes(address_path):
 
-    address_path_tag = address_path + [address]
+        address_path_tag = address_path + [address]
+
+        # Move web-option as a configuration in each service instead of top level web-option
+        if config.exists(address_path_tag + ['web-options']) and address == 'web':
+            for svc_type in ['service', 'rfc2136']:
+                if config.exists(address_path_tag + [svc_type]):
+                    for svc_cfg in config.list_nodes(address_path_tag + [svc_type]):
+                        config.copy(address_path_tag + ['web-options'],
+                                    address_path_tag + [svc_type, svc_cfg, 'web-options'])
+            config.delete(address_path_tag + ['web-options'])
 
-    # Move web-option as a configuration in each service instead of top level web-option
-    if config.exists(address_path_tag + ['web-options']) and address == 'web':
         for svc_type in ['service', 'rfc2136']:
             if config.exists(address_path_tag + [svc_type]):
+                # Set protocol to 'nsupdate' for RFC2136 configuration
+                if svc_type == 'rfc2136':
+                    for rfc_cfg in config.list_nodes(address_path_tag + ['rfc2136']):
+                        config.set(address_path_tag + ['rfc2136', rfc_cfg, 'protocol'], 'nsupdate')
+
+                # Add address as config value in each service before moving the service path
+                # And then copy the services from 'address <interface> service <service>'
+                #                              to 'name (service|rfc2136)-<service>-<address>'
+                # Note: The new service is named (service|rfc2136)-<service>-<address>
+                #       to avoid name conflict with old entries
                 for svc_cfg in config.list_nodes(address_path_tag + [svc_type]):
-                    config.copy(address_path_tag + ['web-options'],
-                                address_path_tag + [svc_type, svc_cfg, 'web-options'])
-        config.delete(address_path_tag + ['web-options'])
-
-    for svc_type in ['service', 'rfc2136']:
-        if config.exists(address_path_tag + [svc_type]):
-            # Set protocol to 'nsupdate' for RFC2136 configuration
-            if svc_type == 'rfc2136':
-                for rfc_cfg in config.list_nodes(address_path_tag + ['rfc2136']):
-                    config.set(address_path_tag + ['rfc2136', rfc_cfg, 'protocol'], 'nsupdate')
-
-            # Add address as config value in each service before moving the service path
-            # And then copy the services from 'address <interface> service <service>'
-            #                              to 'name (service|rfc2136)-<service>-<address>'
-            # Note: The new service is named (service|rfc2136)-<service>-<address>
-            #       to avoid name conflict with old entries
-            for svc_cfg in config.list_nodes(address_path_tag + [svc_type]):
-                config.set(address_path_tag + [svc_type, svc_cfg, 'address'], address)
-                config.copy(address_path_tag + [svc_type, svc_cfg],
-                            name_path + ['-'.join([svc_type, svc_cfg, address])])
-
-# Finally cleanup the old address path
-config.delete(address_path)
-
-# Normalize the all service names to conform with name constraints
-index = 1
-for name in config.list_nodes(name_path):
-    new_name = normalize_name(name)
-    if new_name != name:
-        # Append index if there is still a name conflicts after normalization
-        # For example, "foo-?(" and "foo-!)" both normalize to "foo-"
-        if config.exists(name_path + [new_name]):
-            new_name = f'{new_name}-{index}'
-            index += 1
-        config.rename(name_path + [name], new_name)
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+                    config.set(address_path_tag + [svc_type, svc_cfg, 'address'], address)
+                    config.copy(address_path_tag + [svc_type, svc_cfg],
+                                name_path + ['-'.join([svc_type, svc_cfg, address])])
+
+    # Finally cleanup the old address path
+    config.delete(address_path)
+
+    # Normalize the all service names to conform with name constraints
+    index = 1
+    for name in config.list_nodes(name_path):
+        new_name = normalize_name(name)
+        if new_name != name:
+            # Append index if there is still a name conflicts after normalization
+            # For example, "foo-?(" and "foo-!)" both normalize to "foo-"
+            if config.exists(name_path + [new_name]):
+                new_name = f'{new_name}-{index}'
+                index += 1
+            config.rename(name_path + [name], new_name)
diff --git a/src/migration-scripts/dns-dynamic/3-to-4 b/src/migration-scripts/dns-dynamic/3-to-4
old mode 100755
new mode 100644
index b888a3b6b..c8e1ffeee
--- a/src/migration-scripts/dns-dynamic/3-to-4
+++ b/src/migration-scripts/dns-dynamic/3-to-4
@@ -1,76 +1,57 @@
-#!/usr/bin/env python3
-
-# Copyright (C) 2024 VyOS maintainers and contributors
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5966:
 # - migrate "service dns dynamic name <service> address <interface>"
 #        to "service dns dynamic name <service> address interface <interface>"
 #      when <interface> != 'web'
 # - migrate "service dns dynamic name <service> web-options ..."
 #        to "service dns dynamic name <service> address web ..."
 #      when <interface> == 'web'
 
-import sys
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 base_path = ['service', 'dns', 'dynamic', 'name']
 
-if not config.exists(base_path):
-    # Nothing to do
-    sys.exit(0)
-
-for service in config.list_nodes(base_path):
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base_path):
+        # Nothing to do
+        return
 
-    service_path = base_path + [service]
+    for service in config.list_nodes(base_path):
 
-    if config.exists(service_path + ['address']):
-        address = config.return_value(service_path + ['address'])
-        # 'address' is not a leaf node anymore, delete it first
-        config.delete(service_path + ['address'])
+        service_path = base_path + [service]
 
-        # When address is an interface (not 'web'), move it to 'address interface'
-        if address != 'web':
-            config.set(service_path + ['address', 'interface'], address)
+        if config.exists(service_path + ['address']):
+            address = config.return_value(service_path + ['address'])
+            # 'address' is not a leaf node anymore, delete it first
+            config.delete(service_path + ['address'])
 
-        else: # address == 'web'
-            # Relocate optional 'web-options' directly under 'address web'
-            if config.exists(service_path + ['web-options']):
-                # config.copy does not recursively create a path, so initialize it
-                config.set(service_path + ['address'])
-                config.copy(service_path + ['web-options'],
-                            service_path + ['address', 'web'])
-                config.delete(service_path + ['web-options'])
+            # When address is an interface (not 'web'), move it to 'address interface'
+            if address != 'web':
+                config.set(service_path + ['address', 'interface'], address)
 
-            # ensure that valueless 'address web' still exists even if there are no 'web-options'
-            if not config.exists(service_path + ['address', 'web']):
-                config.set(service_path + ['address', 'web'])
+            else: # address == 'web'
+                # Relocate optional 'web-options' directly under 'address web'
+                if config.exists(service_path + ['web-options']):
+                    # config.copy does not recursively create a path, so initialize it
+                    config.set(service_path + ['address'])
+                    config.copy(service_path + ['web-options'],
+                                service_path + ['address', 'web'])
+                    config.delete(service_path + ['web-options'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+                # ensure that valueless 'address web' still exists even if there are no 'web-options'
+                if not config.exists(service_path + ['address', 'web']):
+                    config.set(service_path + ['address', 'web'])
diff --git a/src/migration-scripts/dns-forwarding/0-to-1 b/src/migration-scripts/dns-forwarding/0-to-1
old mode 100755
new mode 100644
index 7f4343652..264ffb40d
--- a/src/migration-scripts/dns-forwarding/0-to-1
+++ b/src/migration-scripts/dns-forwarding/0-to-1
@@ -1,50 +1,31 @@
-#!/usr/bin/env python3
+# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2019 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # This migration script will check if there is a allow-from directive configured
 # for the dns forwarding service - if not, the node will be created with the old
 # default values of 0.0.0.0/0 and ::/0
 
-import sys
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
+base = ['service', 'dns', 'forwarding']
 
-config = ConfigTree(config_file)
+def migrate(config: ConfigTree)-> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-base = ['service', 'dns', 'forwarding']
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
-else:
     if not config.exists(base + ['allow-from']):
         config.set(base + ['allow-from'], value='0.0.0.0/0', replace=False)
         config.set(base + ['allow-from'], value='::/0', replace=False)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/dns-forwarding/1-to-2 b/src/migration-scripts/dns-forwarding/1-to-2
old mode 100755
new mode 100644
index 7df2d47e2..15ed1e136
--- a/src/migration-scripts/dns-forwarding/1-to-2
+++ b/src/migration-scripts/dns-forwarding/1-to-2
@@ -1,86 +1,67 @@
-#!/usr/bin/env python3
+# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2019 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # This migration script will remove the deprecated 'listen-on' statement
 # from the dns forwarding service and will add the corresponding
 # listen-address nodes instead. This is required as PowerDNS can only listen
 # on interface addresses and not on interface names.
 
 from ipaddress import ip_interface
-from sys import argv, exit
 from vyos.ifconfig import Interface
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 base = ['service', 'dns', 'forwarding']
-if not config.exists(base + ['listen-on']):
-    # Nothing to do
-    exit(0)
 
-listen_intf = config.return_values(base + ['listen-on'])
-# Delete node with abandoned command
-config.delete(base + ['listen-on'])
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base + ['listen-on']):
+        # Nothing to do
+        return
 
-# retrieve interface addresses for every configured listen-on interface
-listen_addr = []
-for intf in listen_intf:
-    # we need to evaluate the interface section before manipulating the 'intf' variable
-    section = Interface.section(intf)
-    if not section:
-        raise ValueError(f'Invalid interface name {intf}')
+    listen_intf = config.return_values(base + ['listen-on'])
+    # Delete node with abandoned command
+    config.delete(base + ['listen-on'])
 
-    # we need to treat vif and vif-s interfaces differently,
-    # both "real interfaces" use dots for vlan identifiers - those
-    # need to be exchanged with vif and vif-s identifiers
-    if intf.count('.') == 1:
-        # this is a regular VLAN interface
-        intf = intf.split('.')[0] + ' vif ' + intf.split('.')[1]
-    elif intf.count('.') == 2:
-        # this is a QinQ VLAN interface
-        intf = intf.split('.')[0] + ' vif-s ' + intf.split('.')[1] + ' vif-c ' +  intf.split('.')[2]
+    # retrieve interface addresses for every configured listen-on interface
+    listen_addr = []
+    for intf in listen_intf:
+        # we need to evaluate the interface section before manipulating the 'intf' variable
+        section = Interface.section(intf)
+        if not section:
+            raise ValueError(f'Invalid interface name {intf}')
 
-    # retrieve corresponding interface addresses in CIDR format
-    # those need to be converted in pure IP addresses without network information
-    path = ['interfaces', section, intf, 'address']
-    try:
-        for addr in config.return_values(path):
-            listen_addr.append( ip_interface(addr).ip )
-    except:
-        # Some interface types do not use "address" option (e.g. OpenVPN)
-        # and may not even have a fixed address
-        print("Could not retrieve the address of the interface {} from the config".format(intf))
-        print("You will need to update your DNS forwarding configuration manually")
+        # we need to treat vif and vif-s interfaces differently,
+        # both "real interfaces" use dots for vlan identifiers - those
+        # need to be exchanged with vif and vif-s identifiers
+        if intf.count('.') == 1:
+            # this is a regular VLAN interface
+            intf = intf.split('.')[0] + ' vif ' + intf.split('.')[1]
+        elif intf.count('.') == 2:
+            # this is a QinQ VLAN interface
+            intf = intf.split('.')[0] + ' vif-s ' + intf.split('.')[1] + ' vif-c ' +  intf.split('.')[2]
 
-for addr in listen_addr:
-    config.set(base + ['listen-address'], value=addr, replace=False)
+        # retrieve corresponding interface addresses in CIDR format
+        # those need to be converted in pure IP addresses without network information
+        path = ['interfaces', section, intf, 'address']
+        try:
+            for addr in config.return_values(path):
+                listen_addr.append( ip_interface(addr).ip )
+        except:
+            # Some interface types do not use "address" option (e.g. OpenVPN)
+            # and may not even have a fixed address
+            print("Could not retrieve the address of the interface {} from the config".format(intf))
+            print("You will need to update your DNS forwarding configuration manually")
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+    for addr in listen_addr:
+        config.set(base + ['listen-address'], value=addr, replace=False)
diff --git a/src/migration-scripts/dns-forwarding/2-to-3 b/src/migration-scripts/dns-forwarding/2-to-3
old mode 100755
new mode 100644
index d7ff9e260..729c1f00a
--- a/src/migration-scripts/dns-forwarding/2-to-3
+++ b/src/migration-scripts/dns-forwarding/2-to-3
@@ -1,51 +1,32 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Sets the new options "addnta" and "recursion-desired" for all
 # 'dns forwarding domain' as this is usually desired
 
-import sys
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 base = ['service', 'dns', 'forwarding']
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
 
-if config.exists(base + ['domain']):
-    for domain in config.list_nodes(base + ['domain']):
-        domain_base = base + ['domain', domain]
-        config.set(domain_base + ['addnta'])
-        config.set(domain_base + ['recursion-desired'])
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
+    if config.exists(base + ['domain']):
+        for domain in config.list_nodes(base + ['domain']):
+            domain_base = base + ['domain', domain]
+            config.set(domain_base + ['addnta'])
+            config.set(domain_base + ['recursion-desired'])
diff --git a/src/migration-scripts/dns-forwarding/3-to-4 b/src/migration-scripts/dns-forwarding/3-to-4
old mode 100755
new mode 100644
index 3d5316ed4..b02c0b7ca
--- a/src/migration-scripts/dns-forwarding/3-to-4
+++ b/src/migration-scripts/dns-forwarding/3-to-4
@@ -1,49 +1,31 @@
-#!/usr/bin/env python3
-
-# Copyright (C) 2023 VyOS maintainers and contributors
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5115: migrate "service dns forwarding domain example.com server" to
 #                "service dns forwarding domain example.com name-server"
 
-import sys
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 base = ['service', 'dns', 'forwarding', 'domain']
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
 
-for domain in config.list_nodes(base):
-    if config.exists(base + [domain, 'server']):
-        config.copy(base + [domain, 'server'], base + [domain, 'name-server'])
-        config.delete(base + [domain, 'server'])
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+    for domain in config.list_nodes(base):
+        if config.exists(base + [domain, 'server']):
+            config.copy(base + [domain, 'server'], base + [domain, 'name-server'])
+            config.delete(base + [domain, 'server'])
diff --git a/src/migration-scripts/firewall/10-to-11 b/src/migration-scripts/firewall/10-to-11
old mode 100755
new mode 100644
index 854d5a558..70a170940
--- a/src/migration-scripts/firewall/10-to-11
+++ b/src/migration-scripts/firewall/10-to-11
@@ -1,207 +1,187 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5160: Firewall re-writing
 
 #  cli changes from:
 #  set firewall name <name> ...
 #  set firewall ipv6-name <name> ...
 #  To
 #  set firewall ipv4 name <name>
 #  set firewall ipv6 name <name>
 
 ## Also from 'firewall interface' removed.
 ## in and out:
     # set firewall interface <iface> [in|out] [name | ipv6-name] <name>
     # To
     # set firewall [ipv4 | ipv6] forward filter rule <5,10,15,...> [inbound-interface | outboubd-interface] interface-name <iface>
     # set firewall [ipv4 | ipv6] forward filter rule <5,10,15,...> action jump
     # set firewall [ipv4 | ipv6] forward filter rule <5,10,15,...> jump-target <name>
 ## local:
     # set firewall interface <iface> local [name | ipv6-name] <name>
     # To
     # set firewall [ipv4 | ipv6] input filter rule <5,10,15,...> inbound-interface interface-name <iface>
     # set firewall [ipv4 | ipv6] input filter rule <5,10,15,...> action jump
     # set firewall [ipv4 | ipv6] input filter rule <5,10,15,...> jump-target <name>
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['firewall']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-### Migration of state policies
-if config.exists(base + ['state-policy']):
-    for state in config.list_nodes(base + ['state-policy']):
-        action = config.return_value(base + ['state-policy', state, 'action'])
-        config.set(base + ['global-options', 'state-policy', state, 'action'], value=action)
-        if config.exists(base + ['state-policy', state, 'log']):
-            config.set(base + ['global-options', 'state-policy', state, 'log'], value='enable')
-    config.delete(base + ['state-policy'])
-
-## migration of global options:
-for option in ['all-ping', 'broadcast-ping', 'config-trap', 'ip-src-route', 'ipv6-receive-redirects', 'ipv6-src-route', 'log-martians',
-                'receive-redirects', 'resolver-cache', 'resolver-internal', 'send-redirects', 'source-validation', 'syn-cookies', 'twa-hazards-protection']:
-    if config.exists(base + [option]):
-        if option != 'config-trap':
-            val = config.return_value(base + [option])
-            config.set(base + ['global-options', option], value=val)
-        config.delete(base + [option])
-
-### Migration of firewall name and ipv6-name
-### Also migrate legacy 'accept' behaviour
-if config.exists(base + ['name']):
-    config.set(['firewall', 'ipv4', 'name'])
-    config.set_tag(['firewall', 'ipv4', 'name'])
-
-    for ipv4name in config.list_nodes(base + ['name']):
-        config.copy(base + ['name', ipv4name], base + ['ipv4', 'name', ipv4name])
-
-        if config.exists(base + ['ipv4', 'name', ipv4name, 'default-action']):
-            action = config.return_value(base + ['ipv4', 'name', ipv4name, 'default-action'])
-
-            if action == 'accept':
-                config.set(base + ['ipv4', 'name', ipv4name, 'default-action'], value='return')
-
-        if config.exists(base + ['ipv4', 'name', ipv4name, 'rule']):
-            for rule_id in config.list_nodes(base + ['ipv4', 'name', ipv4name, 'rule']):
-                action = config.return_value(base + ['ipv4', 'name', ipv4name, 'rule', rule_id, 'action'])
+
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    ### Migration of state policies
+    if config.exists(base + ['state-policy']):
+        for state in config.list_nodes(base + ['state-policy']):
+            action = config.return_value(base + ['state-policy', state, 'action'])
+            config.set(base + ['global-options', 'state-policy', state, 'action'], value=action)
+            if config.exists(base + ['state-policy', state, 'log']):
+                config.set(base + ['global-options', 'state-policy', state, 'log'], value='enable')
+        config.delete(base + ['state-policy'])
+
+    ## migration of global options:
+    for option in ['all-ping', 'broadcast-ping', 'config-trap', 'ip-src-route', 'ipv6-receive-redirects', 'ipv6-src-route', 'log-martians',
+                    'receive-redirects', 'resolver-cache', 'resolver-internal', 'send-redirects', 'source-validation', 'syn-cookies', 'twa-hazards-protection']:
+        if config.exists(base + [option]):
+            if option != 'config-trap':
+                val = config.return_value(base + [option])
+                config.set(base + ['global-options', option], value=val)
+            config.delete(base + [option])
+
+    ### Migration of firewall name and ipv6-name
+    ### Also migrate legacy 'accept' behaviour
+    if config.exists(base + ['name']):
+        config.set(['firewall', 'ipv4', 'name'])
+        config.set_tag(['firewall', 'ipv4', 'name'])
+
+        for ipv4name in config.list_nodes(base + ['name']):
+            config.copy(base + ['name', ipv4name], base + ['ipv4', 'name', ipv4name])
+
+            if config.exists(base + ['ipv4', 'name', ipv4name, 'default-action']):
+                action = config.return_value(base + ['ipv4', 'name', ipv4name, 'default-action'])
 
                 if action == 'accept':
-                    config.set(base + ['ipv4', 'name', ipv4name, 'rule', rule_id, 'action'], value='return')
+                    config.set(base + ['ipv4', 'name', ipv4name, 'default-action'], value='return')
 
-    config.delete(base + ['name'])
+            if config.exists(base + ['ipv4', 'name', ipv4name, 'rule']):
+                for rule_id in config.list_nodes(base + ['ipv4', 'name', ipv4name, 'rule']):
+                    action = config.return_value(base + ['ipv4', 'name', ipv4name, 'rule', rule_id, 'action'])
 
-if config.exists(base + ['ipv6-name']):
-    config.set(['firewall', 'ipv6', 'name'])
-    config.set_tag(['firewall', 'ipv6', 'name'])
+                    if action == 'accept':
+                        config.set(base + ['ipv4', 'name', ipv4name, 'rule', rule_id, 'action'], value='return')
 
-    for ipv6name in config.list_nodes(base + ['ipv6-name']):
-        config.copy(base + ['ipv6-name', ipv6name], base + ['ipv6', 'name', ipv6name])
+        config.delete(base + ['name'])
 
-        if config.exists(base + ['ipv6', 'name', ipv6name, 'default-action']):
-            action = config.return_value(base + ['ipv6', 'name', ipv6name, 'default-action'])
+    if config.exists(base + ['ipv6-name']):
+        config.set(['firewall', 'ipv6', 'name'])
+        config.set_tag(['firewall', 'ipv6', 'name'])
 
-            if action == 'accept':
-                config.set(base + ['ipv6', 'name', ipv6name, 'default-action'], value='return')
+        for ipv6name in config.list_nodes(base + ['ipv6-name']):
+            config.copy(base + ['ipv6-name', ipv6name], base + ['ipv6', 'name', ipv6name])
 
-        if config.exists(base + ['ipv6', 'name', ipv6name, 'rule']):
-            for rule_id in config.list_nodes(base + ['ipv6', 'name', ipv6name, 'rule']):
-                action = config.return_value(base + ['ipv6', 'name', ipv6name, 'rule', rule_id, 'action'])
+            if config.exists(base + ['ipv6', 'name', ipv6name, 'default-action']):
+                action = config.return_value(base + ['ipv6', 'name', ipv6name, 'default-action'])
 
                 if action == 'accept':
-                    config.set(base + ['ipv6', 'name', ipv6name, 'rule', rule_id, 'action'], value='return')
-
-    config.delete(base + ['ipv6-name'])
-
-### Migration of firewall interface
-if config.exists(base + ['interface']):
-    fwd_ipv4_rule = 5
-    inp_ipv4_rule = 5
-    fwd_ipv6_rule = 5
-    inp_ipv6_rule = 5
-    for direction in ['in', 'out', 'local']:
-        for iface in config.list_nodes(base + ['interface']):
-            if config.exists(base + ['interface', iface, direction]):
-                if config.exists(base + ['interface', iface, direction, 'name']):
-                    target = config.return_value(base + ['interface', iface, direction, 'name'])
-                    if direction == 'in':
-                        # Add default-action== accept for compatibility reasons:
-                        config.set(base + ['ipv4', 'forward', 'filter', 'default-action'], value='accept')
-                        new_base = base + ['ipv4', 'forward', 'filter', 'rule']
-                        config.set(new_base)
-                        config.set_tag(new_base)
-                        config.set(new_base + [fwd_ipv4_rule, 'inbound-interface', 'interface-name'], value=iface)
-                        config.set(new_base + [fwd_ipv4_rule, 'action'], value='jump')
-                        config.set(new_base + [fwd_ipv4_rule, 'jump-target'], value=target)
-                        fwd_ipv4_rule = fwd_ipv4_rule + 5
-                    elif direction == 'out':
-                        # Add default-action== accept for compatibility reasons:
-                        config.set(base + ['ipv4', 'forward', 'filter', 'default-action'], value='accept')
-                        new_base = base + ['ipv4', 'forward', 'filter', 'rule']
-                        config.set(new_base)
-                        config.set_tag(new_base)
-                        config.set(new_base + [fwd_ipv4_rule, 'outbound-interface', 'interface-name'], value=iface)
-                        config.set(new_base + [fwd_ipv4_rule, 'action'], value='jump')
-                        config.set(new_base + [fwd_ipv4_rule, 'jump-target'], value=target)
-                        fwd_ipv4_rule = fwd_ipv4_rule + 5
-                    else:
-                        # Add default-action== accept for compatibility reasons:
-                        config.set(base + ['ipv4', 'input', 'filter', 'default-action'], value='accept')
-                        new_base = base + ['ipv4', 'input', 'filter', 'rule']
-                        config.set(new_base)
-                        config.set_tag(new_base)
-                        config.set(new_base + [inp_ipv4_rule, 'inbound-interface', 'interface-name'], value=iface)
-                        config.set(new_base + [inp_ipv4_rule, 'action'], value='jump')
-                        config.set(new_base + [inp_ipv4_rule, 'jump-target'], value=target)
-                        inp_ipv4_rule = inp_ipv4_rule + 5
-
-                if config.exists(base + ['interface', iface, direction, 'ipv6-name']):
-                    target = config.return_value(base + ['interface', iface, direction, 'ipv6-name'])
-                    if direction == 'in':
-                        # Add default-action== accept for compatibility reasons:
-                        config.set(base + ['ipv6', 'forward', 'filter', 'default-action'], value='accept')
-                        new_base = base + ['ipv6', 'forward', 'filter', 'rule']
-                        config.set(new_base)
-                        config.set_tag(new_base)
-                        config.set(new_base + [fwd_ipv6_rule, 'inbound-interface', 'interface-name'], value=iface)
-                        config.set(new_base + [fwd_ipv6_rule, 'action'], value='jump')
-                        config.set(new_base + [fwd_ipv6_rule, 'jump-target'], value=target)
-                        fwd_ipv6_rule = fwd_ipv6_rule + 5
-                    elif direction == 'out':
-                        # Add default-action== accept for compatibility reasons:
-                        config.set(base + ['ipv6', 'forward', 'filter', 'default-action'], value='accept')
-                        new_base = base + ['ipv6', 'forward', 'filter', 'rule']
-                        config.set(new_base)
-                        config.set_tag(new_base)
-                        config.set(new_base + [fwd_ipv6_rule, 'outbound-interface', 'interface-name'], value=iface)
-                        config.set(new_base + [fwd_ipv6_rule, 'action'], value='jump')
-                        config.set(new_base + [fwd_ipv6_rule, 'jump-target'], value=target)
-                        fwd_ipv6_rule = fwd_ipv6_rule + 5
-                    else:
-                        new_base = base + ['ipv6', 'input', 'filter', 'rule']
-                        # Add default-action== accept for compatibility reasons:
-                        config.set(base + ['ipv6', 'input', 'filter', 'default-action'], value='accept')
-                        config.set(new_base)
-                        config.set_tag(new_base)
-                        config.set(new_base + [inp_ipv6_rule, 'inbound-interface', 'interface-name'], value=iface)
-                        config.set(new_base + [inp_ipv6_rule, 'action'], value='jump')
-                        config.set(new_base + [inp_ipv6_rule, 'jump-target'], value=target)
-                        inp_ipv6_rule = inp_ipv6_rule + 5
-
-    config.delete(base + ['interface'])
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+                    config.set(base + ['ipv6', 'name', ipv6name, 'default-action'], value='return')
+
+            if config.exists(base + ['ipv6', 'name', ipv6name, 'rule']):
+                for rule_id in config.list_nodes(base + ['ipv6', 'name', ipv6name, 'rule']):
+                    action = config.return_value(base + ['ipv6', 'name', ipv6name, 'rule', rule_id, 'action'])
+
+                    if action == 'accept':
+                        config.set(base + ['ipv6', 'name', ipv6name, 'rule', rule_id, 'action'], value='return')
+
+        config.delete(base + ['ipv6-name'])
+
+    ### Migration of firewall interface
+    if config.exists(base + ['interface']):
+        fwd_ipv4_rule = 5
+        inp_ipv4_rule = 5
+        fwd_ipv6_rule = 5
+        inp_ipv6_rule = 5
+        for direction in ['in', 'out', 'local']:
+            for iface in config.list_nodes(base + ['interface']):
+                if config.exists(base + ['interface', iface, direction]):
+                    if config.exists(base + ['interface', iface, direction, 'name']):
+                        target = config.return_value(base + ['interface', iface, direction, 'name'])
+                        if direction == 'in':
+                            # Add default-action== accept for compatibility reasons:
+                            config.set(base + ['ipv4', 'forward', 'filter', 'default-action'], value='accept')
+                            new_base = base + ['ipv4', 'forward', 'filter', 'rule']
+                            config.set(new_base)
+                            config.set_tag(new_base)
+                            config.set(new_base + [fwd_ipv4_rule, 'inbound-interface', 'interface-name'], value=iface)
+                            config.set(new_base + [fwd_ipv4_rule, 'action'], value='jump')
+                            config.set(new_base + [fwd_ipv4_rule, 'jump-target'], value=target)
+                            fwd_ipv4_rule = fwd_ipv4_rule + 5
+                        elif direction == 'out':
+                            # Add default-action== accept for compatibility reasons:
+                            config.set(base + ['ipv4', 'forward', 'filter', 'default-action'], value='accept')
+                            new_base = base + ['ipv4', 'forward', 'filter', 'rule']
+                            config.set(new_base)
+                            config.set_tag(new_base)
+                            config.set(new_base + [fwd_ipv4_rule, 'outbound-interface', 'interface-name'], value=iface)
+                            config.set(new_base + [fwd_ipv4_rule, 'action'], value='jump')
+                            config.set(new_base + [fwd_ipv4_rule, 'jump-target'], value=target)
+                            fwd_ipv4_rule = fwd_ipv4_rule + 5
+                        else:
+                            # Add default-action== accept for compatibility reasons:
+                            config.set(base + ['ipv4', 'input', 'filter', 'default-action'], value='accept')
+                            new_base = base + ['ipv4', 'input', 'filter', 'rule']
+                            config.set(new_base)
+                            config.set_tag(new_base)
+                            config.set(new_base + [inp_ipv4_rule, 'inbound-interface', 'interface-name'], value=iface)
+                            config.set(new_base + [inp_ipv4_rule, 'action'], value='jump')
+                            config.set(new_base + [inp_ipv4_rule, 'jump-target'], value=target)
+                            inp_ipv4_rule = inp_ipv4_rule + 5
+
+                    if config.exists(base + ['interface', iface, direction, 'ipv6-name']):
+                        target = config.return_value(base + ['interface', iface, direction, 'ipv6-name'])
+                        if direction == 'in':
+                            # Add default-action== accept for compatibility reasons:
+                            config.set(base + ['ipv6', 'forward', 'filter', 'default-action'], value='accept')
+                            new_base = base + ['ipv6', 'forward', 'filter', 'rule']
+                            config.set(new_base)
+                            config.set_tag(new_base)
+                            config.set(new_base + [fwd_ipv6_rule, 'inbound-interface', 'interface-name'], value=iface)
+                            config.set(new_base + [fwd_ipv6_rule, 'action'], value='jump')
+                            config.set(new_base + [fwd_ipv6_rule, 'jump-target'], value=target)
+                            fwd_ipv6_rule = fwd_ipv6_rule + 5
+                        elif direction == 'out':
+                            # Add default-action== accept for compatibility reasons:
+                            config.set(base + ['ipv6', 'forward', 'filter', 'default-action'], value='accept')
+                            new_base = base + ['ipv6', 'forward', 'filter', 'rule']
+                            config.set(new_base)
+                            config.set_tag(new_base)
+                            config.set(new_base + [fwd_ipv6_rule, 'outbound-interface', 'interface-name'], value=iface)
+                            config.set(new_base + [fwd_ipv6_rule, 'action'], value='jump')
+                            config.set(new_base + [fwd_ipv6_rule, 'jump-target'], value=target)
+                            fwd_ipv6_rule = fwd_ipv6_rule + 5
+                        else:
+                            new_base = base + ['ipv6', 'input', 'filter', 'rule']
+                            # Add default-action== accept for compatibility reasons:
+                            config.set(base + ['ipv6', 'input', 'filter', 'default-action'], value='accept')
+                            config.set(new_base)
+                            config.set_tag(new_base)
+                            config.set(new_base + [inp_ipv6_rule, 'inbound-interface', 'interface-name'], value=iface)
+                            config.set(new_base + [inp_ipv6_rule, 'action'], value='jump')
+                            config.set(new_base + [inp_ipv6_rule, 'jump-target'], value=target)
+                            inp_ipv6_rule = inp_ipv6_rule + 5
+
+        config.delete(base + ['interface'])
diff --git a/src/migration-scripts/firewall/11-to-12 b/src/migration-scripts/firewall/11-to-12
old mode 100755
new mode 100644
index f9122e74c..80a74cca9
--- a/src/migration-scripts/firewall/11-to-12
+++ b/src/migration-scripts/firewall/11-to-12
@@ -1,71 +1,51 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5681: Firewall re-writing. Simplify cli when mathcing interface
 # From
     # set firewall ... rule <rule> [inbound-interface | outboubd-interface] interface-name <iface>
     # set firewall ... rule <rule> [inbound-interface | outboubd-interface] interface-group <iface_group>
 # To
     # set firewall ... rule <rule> [inbound-interface | outboubd-interface] name <iface>
     # set firewall ... rule <rule> [inbound-interface | outboubd-interface] group <iface_group>
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['firewall']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-## Migration from base chains
-#if config.exists(base + ['interface', iface, direction]):
-for family in ['ipv4', 'ipv6']:
-    if config.exists(base + [family]):
-        for hook in ['forward', 'input', 'output', 'name']:
-            if config.exists(base + [family, hook]):
-                for priority in config.list_nodes(base + [family, hook]):
-                    if config.exists(base + [family, hook, priority, 'rule']):
-                        for rule in config.list_nodes(base + [family, hook, priority, 'rule']):
-                            for direction in ['inbound-interface', 'outbound-interface']:
-                                if config.exists(base + [family, hook, priority, 'rule', rule, direction]):
-                                    if config.exists(base + [family, hook, priority, 'rule', rule, direction, 'interface-name']):
-                                        iface = config.return_value(base + [family, hook, priority, 'rule', rule, direction, 'interface-name'])
-                                        config.set(base + [family, hook, priority, 'rule', rule, direction, 'name'], value=iface)
-                                        config.delete(base + [family, hook, priority, 'rule', rule, direction, 'interface-name'])
-                                    elif config.exists(base + [family, hook, priority, 'rule', rule, direction, 'interface-group']):
-                                        group = config.return_value(base + [family, hook, priority, 'rule', rule, direction, 'interface-group'])
-                                        config.set(base + [family, hook, priority, 'rule', rule, direction, 'group'], value=group)
-                                        config.delete(base + [family, hook, priority, 'rule', rule, direction, 'interface-group'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    ## Migration from base chains
+    #if config.exists(base + ['interface', iface, direction]):
+    for family in ['ipv4', 'ipv6']:
+        if config.exists(base + [family]):
+            for hook in ['forward', 'input', 'output', 'name']:
+                if config.exists(base + [family, hook]):
+                    for priority in config.list_nodes(base + [family, hook]):
+                        if config.exists(base + [family, hook, priority, 'rule']):
+                            for rule in config.list_nodes(base + [family, hook, priority, 'rule']):
+                                for direction in ['inbound-interface', 'outbound-interface']:
+                                    if config.exists(base + [family, hook, priority, 'rule', rule, direction]):
+                                        if config.exists(base + [family, hook, priority, 'rule', rule, direction, 'interface-name']):
+                                            iface = config.return_value(base + [family, hook, priority, 'rule', rule, direction, 'interface-name'])
+                                            config.set(base + [family, hook, priority, 'rule', rule, direction, 'name'], value=iface)
+                                            config.delete(base + [family, hook, priority, 'rule', rule, direction, 'interface-name'])
+                                        elif config.exists(base + [family, hook, priority, 'rule', rule, direction, 'interface-group']):
+                                            group = config.return_value(base + [family, hook, priority, 'rule', rule, direction, 'interface-group'])
+                                            config.set(base + [family, hook, priority, 'rule', rule, direction, 'group'], value=group)
+                                            config.delete(base + [family, hook, priority, 'rule', rule, direction, 'interface-group'])
diff --git a/src/migration-scripts/firewall/12-to-13 b/src/migration-scripts/firewall/12-to-13
old mode 100755
new mode 100644
index d72ba834d..d7b801cd3
--- a/src/migration-scripts/firewall/12-to-13
+++ b/src/migration-scripts/firewall/12-to-13
@@ -1,89 +1,69 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5729: Switch to valueless whenever is possible.
 # From
     # set firewall ... rule <rule> log enable
     # set firewall ... rule <rule> state <state> enable
     # set firewall ... rule <rule> log disable
     # set firewall ... rule <rule> state <state> disable
 # To
     # set firewall ... rule <rule> log
     # set firewall ... rule <rule> state <state>
     # Remove command if log=disable or <state>=disable
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['firewall']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
 
-# State Policy logs:
-if config.exists(base + ['global-options', 'state-policy']):
-    for state in config.list_nodes(base + ['global-options', 'state-policy']):
-        if config.exists(base + ['global-options', 'state-policy', state, 'log']):
-            log_value = config.return_value(base + ['global-options', 'state-policy', state, 'log'])
-            config.delete(base + ['global-options', 'state-policy', state, 'log'])
-            if log_value == 'enable':
-                config.set(base + ['global-options', 'state-policy', state, 'log'])
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-for family in ['ipv4', 'ipv6', 'bridge']:
-    if config.exists(base + [family]):
-        for hook in ['forward', 'input', 'output', 'name']:
-            if config.exists(base + [family, hook]):
-                for priority in config.list_nodes(base + [family, hook]):
-                    if config.exists(base + [family, hook, priority, 'rule']):
-                        for rule in config.list_nodes(base + [family, hook, priority, 'rule']):
-                            # Log
-                            if config.exists(base + [family, hook, priority, 'rule', rule, 'log']):
-                                log_value = config.return_value(base + [family, hook, priority, 'rule', rule, 'log'])
-                                config.delete(base + [family, hook, priority, 'rule', rule, 'log'])
-                                if log_value == 'enable':
-                                    config.set(base + [family, hook, priority, 'rule', rule, 'log'])
-                            # State
-                            if config.exists(base + [family, hook, priority, 'rule', rule, 'state']):
-                                flag_enable = 'False'
-                                for state in ['established', 'invalid', 'new', 'related']:
-                                    if config.exists(base + [family, hook, priority, 'rule', rule, 'state', state]):
-                                        state_value = config.return_value(base + [family, hook, priority, 'rule', rule, 'state', state])
-                                        config.delete(base + [family, hook, priority, 'rule', rule, 'state', state])
-                                        if state_value == 'enable':
-                                            config.set(base + [family, hook, priority, 'rule', rule, 'state'], value=state, replace=False)
-                                            flag_enable = 'True'
-                                if flag_enable == 'False':
-                                    config.delete(base + [family, hook, priority, 'rule', rule, 'state'])
+    # State Policy logs:
+    if config.exists(base + ['global-options', 'state-policy']):
+        for state in config.list_nodes(base + ['global-options', 'state-policy']):
+            if config.exists(base + ['global-options', 'state-policy', state, 'log']):
+                log_value = config.return_value(base + ['global-options', 'state-policy', state, 'log'])
+                config.delete(base + ['global-options', 'state-policy', state, 'log'])
+                if log_value == 'enable':
+                    config.set(base + ['global-options', 'state-policy', state, 'log'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    for family in ['ipv4', 'ipv6', 'bridge']:
+        if config.exists(base + [family]):
+            for hook in ['forward', 'input', 'output', 'name']:
+                if config.exists(base + [family, hook]):
+                    for priority in config.list_nodes(base + [family, hook]):
+                        if config.exists(base + [family, hook, priority, 'rule']):
+                            for rule in config.list_nodes(base + [family, hook, priority, 'rule']):
+                                # Log
+                                if config.exists(base + [family, hook, priority, 'rule', rule, 'log']):
+                                    log_value = config.return_value(base + [family, hook, priority, 'rule', rule, 'log'])
+                                    config.delete(base + [family, hook, priority, 'rule', rule, 'log'])
+                                    if log_value == 'enable':
+                                        config.set(base + [family, hook, priority, 'rule', rule, 'log'])
+                                # State
+                                if config.exists(base + [family, hook, priority, 'rule', rule, 'state']):
+                                    flag_enable = 'False'
+                                    for state in ['established', 'invalid', 'new', 'related']:
+                                        if config.exists(base + [family, hook, priority, 'rule', rule, 'state', state]):
+                                            state_value = config.return_value(base + [family, hook, priority, 'rule', rule, 'state', state])
+                                            config.delete(base + [family, hook, priority, 'rule', rule, 'state', state])
+                                            if state_value == 'enable':
+                                                config.set(base + [family, hook, priority, 'rule', rule, 'state'], value=state, replace=False)
+                                                flag_enable = 'True'
+                                    if flag_enable == 'False':
+                                        config.delete(base + [family, hook, priority, 'rule', rule, 'state'])
diff --git a/src/migration-scripts/firewall/13-to-14 b/src/migration-scripts/firewall/13-to-14
old mode 100755
new mode 100644
index f45ff0674..723b0aea2
--- a/src/migration-scripts/firewall/13-to-14
+++ b/src/migration-scripts/firewall/13-to-14
@@ -1,59 +1,39 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5834: Rename 'enable-default-log' to 'default-log'
 # From
     # set firewall ... filter enable-default-log
     # set firewall ... name <name> enable-default-log
 # To
     # set firewall ... filter default-log
     # set firewall ... name <name> default-log
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['firewall']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-for family in ['ipv4', 'ipv6', 'bridge']:
-    if config.exists(base + [family]):
-        for hook in ['forward', 'input', 'output', 'name']:
-            if config.exists(base + [family, hook]):
-                for priority in config.list_nodes(base + [family, hook]):
-                    if config.exists(base + [family, hook, priority, 'enable-default-log']):
-                        config.rename(base + [family, hook, priority, 'enable-default-log'], 'default-log')
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    for family in ['ipv4', 'ipv6', 'bridge']:
+        if config.exists(base + [family]):
+            for hook in ['forward', 'input', 'output', 'name']:
+                if config.exists(base + [family, hook]):
+                    for priority in config.list_nodes(base + [family, hook]):
+                        if config.exists(base + [family, hook, priority, 'enable-default-log']):
+                            config.rename(base + [family, hook, priority, 'enable-default-log'], 'default-log')
diff --git a/src/migration-scripts/firewall/14-to-15 b/src/migration-scripts/firewall/14-to-15
old mode 100755
new mode 100644
index 735839365..e4a2aaee4
--- a/src/migration-scripts/firewall/14-to-15
+++ b/src/migration-scripts/firewall/14-to-15
@@ -1,46 +1,25 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5535: Migrate <set system ip disable-directed-broadcast> to <set firewall global-options directed-broadcas [enable|disable]
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 base = ['firewall']
 
-if config.exists(['system', 'ip', 'disable-directed-broadcast']):
-    config.set(['firewall', 'global-options', 'directed-broadcast'], value='disable')
-    config.delete(['system', 'ip', 'disable-directed-broadcast'])
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
\ No newline at end of file
+def migrate(config: ConfigTree) -> None:
+    if config.exists(['system', 'ip', 'disable-directed-broadcast']):
+        config.set(['firewall', 'global-options', 'directed-broadcast'], value='disable')
+        config.delete(['system', 'ip', 'disable-directed-broadcast'])
diff --git a/src/migration-scripts/firewall/15-to-16 b/src/migration-scripts/firewall/15-to-16
old mode 100755
new mode 100644
index 28df1256e..8e28bba6f
--- a/src/migration-scripts/firewall/15-to-16
+++ b/src/migration-scripts/firewall/15-to-16
@@ -1,56 +1,37 @@
 #!/usr/bin/env python3
 #
 # Copyright (C) 2022-2024 VyOS maintainers and contributors
 #
 # This program is free software; you can redistribute it and/or modify
 # it under the terms of the GNU General Public License version 2 or later as
 # published by the Free Software Foundation.
 #
 # This program 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 for more details.
 #
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 # T6394: Migrate conntrack timeout options to firewall global-options
     # from: set system conntrack timeout ..
     # to: set firewall global-options timeout ...
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 firewall_base = ['firewall', 'global-options']
 conntrack_base = ['system', 'conntrack', 'timeout']
-config = ConfigTree(config_file)
-
-if not config.exists(conntrack_base):
-    # Nothing to do
-    exit(0)
 
-for protocol in ['icmp', 'tcp', 'udp', 'other']:
-    if config.exists(conntrack_base + [protocol]):
-        if not config.exists(firewall_base + ['timeout']):
-            config.set(firewall_base + ['timeout'])
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(conntrack_base):
+        # Nothing to do
+        return
 
-        config.copy(conntrack_base + [protocol], firewall_base + ['timeout', protocol])
-        config.delete(conntrack_base + [protocol])
+    for protocol in ['icmp', 'tcp', 'udp', 'other']:
+        if config.exists(conntrack_base + [protocol]):
+            if not config.exists(firewall_base + ['timeout']):
+                config.set(firewall_base + ['timeout'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+            config.copy(conntrack_base + [protocol], firewall_base + ['timeout', protocol])
+            config.delete(conntrack_base + [protocol])
diff --git a/src/migration-scripts/firewall/5-to-6 b/src/migration-scripts/firewall/5-to-6
old mode 100755
new mode 100644
index e1eaea7a1..d01684787
--- a/src/migration-scripts/firewall/5-to-6
+++ b/src/migration-scripts/firewall/5-to-6
@@ -1,105 +1,85 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3090: migrate "firewall options interface <name> adjust-mss" to the
 #        individual interface.
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 from vyos.ifconfig import Section
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['firewall', 'options', 'interface']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-for interface in config.list_nodes(base):
-    if config.exists(base + [interface, 'disable']):
-        continue
 
-    if config.exists(base + [interface, 'adjust-mss']):
-        section = Section.section(interface)
-        tmp = config.return_value(base + [interface, 'adjust-mss'])
-
-        vlan = interface.split('.')
-        base_interface_path = ['interfaces', section, vlan[0]]
-
-        if len(vlan) == 1:
-            # Normal interface, no VLAN
-            config.set(base_interface_path + ['ip', 'adjust-mss'], value=tmp)
-        elif len(vlan) == 2:
-            # Regular VIF or VIF-S interface - we need to check the config
-            vif = vlan[1]
-            if config.exists(base_interface_path + ['vif', vif]):
-                config.set(base_interface_path + ['vif', vif, 'ip', 'adjust-mss'], value=tmp)
-            elif config.exists(base_interface_path + ['vif-s', vif]):
-                config.set(base_interface_path + ['vif-s', vif, 'ip', 'adjust-mss'], value=tmp)
-        elif len(vlan) == 3:
-            # VIF-S interface with VIF-C subinterface
-            vif_s = vlan[1]
-            vif_c = vlan[2]
-            config.set(base_interface_path + ['vif-s', vif_s, 'vif-c', vif_c, 'ip', 'adjust-mss'], value=tmp)
-            config.set_tag(base_interface_path + ['vif-s'])
-            config.set_tag(base_interface_path + ['vif-s', vif_s, 'vif-c'])
-
-    if config.exists(base + [interface, 'adjust-mss6']):
-        section = Section.section(interface)
-        tmp = config.return_value(base + [interface, 'adjust-mss6'])
-
-        vlan = interface.split('.')
-        base_interface_path = ['interfaces', section, vlan[0]]
-
-        if len(vlan) == 1:
-            # Normal interface, no VLAN
-            config.set(['interfaces', section, interface, 'ipv6', 'adjust-mss'], value=tmp)
-        elif len(vlan) == 2:
-            # Regular VIF or VIF-S interface - we need to check the config
-            vif = vlan[1]
-            if config.exists(base_interface_path + ['vif', vif]):
-                config.set(base_interface_path + ['vif', vif, 'ipv6', 'adjust-mss'], value=tmp)
-                config.set_tag(base_interface_path + ['vif'])
-            elif config.exists(base_interface_path + ['vif-s', vif]):
-                config.set(base_interface_path + ['vif-s', vif, 'ipv6', 'adjust-mss'], value=tmp)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    for interface in config.list_nodes(base):
+        if config.exists(base + [interface, 'disable']):
+            continue
+
+        if config.exists(base + [interface, 'adjust-mss']):
+            section = Section.section(interface)
+            tmp = config.return_value(base + [interface, 'adjust-mss'])
+
+            vlan = interface.split('.')
+            base_interface_path = ['interfaces', section, vlan[0]]
+
+            if len(vlan) == 1:
+                # Normal interface, no VLAN
+                config.set(base_interface_path + ['ip', 'adjust-mss'], value=tmp)
+            elif len(vlan) == 2:
+                # Regular VIF or VIF-S interface - we need to check the config
+                vif = vlan[1]
+                if config.exists(base_interface_path + ['vif', vif]):
+                    config.set(base_interface_path + ['vif', vif, 'ip', 'adjust-mss'], value=tmp)
+                elif config.exists(base_interface_path + ['vif-s', vif]):
+                    config.set(base_interface_path + ['vif-s', vif, 'ip', 'adjust-mss'], value=tmp)
+            elif len(vlan) == 3:
+                # VIF-S interface with VIF-C subinterface
+                vif_s = vlan[1]
+                vif_c = vlan[2]
+                config.set(base_interface_path + ['vif-s', vif_s, 'vif-c', vif_c, 'ip', 'adjust-mss'], value=tmp)
                 config.set_tag(base_interface_path + ['vif-s'])
-        elif len(vlan) == 3:
-            # VIF-S interface with VIF-C subinterface
-            vif_s = vlan[1]
-            vif_c = vlan[2]
-            config.set(base_interface_path + ['vif-s', vif_s, 'vif-c', vif_c, 'ipv6', 'adjust-mss'], value=tmp)
-            config.set_tag(base_interface_path + ['vif-s'])
-            config.set_tag(base_interface_path + ['vif-s', vif_s, 'vif-c'])
-
-config.delete(['firewall', 'options'])
+                config.set_tag(base_interface_path + ['vif-s', vif_s, 'vif-c'])
+
+        if config.exists(base + [interface, 'adjust-mss6']):
+            section = Section.section(interface)
+            tmp = config.return_value(base + [interface, 'adjust-mss6'])
+
+            vlan = interface.split('.')
+            base_interface_path = ['interfaces', section, vlan[0]]
+
+            if len(vlan) == 1:
+                # Normal interface, no VLAN
+                config.set(['interfaces', section, interface, 'ipv6', 'adjust-mss'], value=tmp)
+            elif len(vlan) == 2:
+                # Regular VIF or VIF-S interface - we need to check the config
+                vif = vlan[1]
+                if config.exists(base_interface_path + ['vif', vif]):
+                    config.set(base_interface_path + ['vif', vif, 'ipv6', 'adjust-mss'], value=tmp)
+                    config.set_tag(base_interface_path + ['vif'])
+                elif config.exists(base_interface_path + ['vif-s', vif]):
+                    config.set(base_interface_path + ['vif-s', vif, 'ipv6', 'adjust-mss'], value=tmp)
+                    config.set_tag(base_interface_path + ['vif-s'])
+            elif len(vlan) == 3:
+                # VIF-S interface with VIF-C subinterface
+                vif_s = vlan[1]
+                vif_c = vlan[2]
+                config.set(base_interface_path + ['vif-s', vif_s, 'vif-c', vif_c, 'ipv6', 'adjust-mss'], value=tmp)
+                config.set_tag(base_interface_path + ['vif-s'])
+                config.set_tag(base_interface_path + ['vif-s', vif_s, 'vif-c'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    config.delete(['firewall', 'options'])
diff --git a/src/migration-scripts/firewall/6-to-7 b/src/migration-scripts/firewall/6-to-7
old mode 100755
new mode 100644
index 938044c6d..1afbc780b
--- a/src/migration-scripts/firewall/6-to-7
+++ b/src/migration-scripts/firewall/6-to-7
@@ -1,322 +1,304 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T2199: Remove unavailable nodes due to XML/Python implementation using nftables
 #        monthdays: nftables does not have a monthdays equivalent
 #        utc: nftables userspace uses localtime and calculates the UTC offset automatically
 #        icmp/v6: migrate previously available `type-name` to valid type/code
 # T4178: Update tcp flags to use multi value node
 # T6071: CLI description limit of 256 characters
 
 import re
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 max_len_description = 255
 
 base = ['firewall']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
 
 icmp_remove = ['any']
 icmp_translations = {
     'ping': 'echo-request',
     'pong': 'echo-reply',
     'ttl-exceeded': 'time-exceeded',
     # Network Unreachable
     'network-unreachable': [3, 0],
     'host-unreachable': [3, 1],
     'protocol-unreachable': [3, 2],
     'port-unreachable': [3, 3],
     'fragmentation-needed': [3, 4],
     'source-route-failed': [3, 5],
     'network-unknown': [3, 6],
     'host-unknown': [3, 7],
     'network-prohibited': [3, 9],
     'host-prohibited': [3, 10],
     'TOS-network-unreachable': [3, 11],
     'TOS-host-unreachable': [3, 12],
     'communication-prohibited': [3, 13],
     'host-precedence-violation': [3, 14],
     'precedence-cutoff': [3, 15],
     # Redirect
     'network-redirect': [5, 0],
     'host-redirect': [5, 1],
     'TOS-network-redirect': [5, 2],
     'TOS host-redirect': [5, 3],
     #  Time Exceeded
     'ttl-zero-during-transit': [11, 0],
     'ttl-zero-during-reassembly': [11, 1],
     'ttl-exceeded': 'time-exceeded',
     # Parameter Problem
     'ip-header-bad': [12, 0],
     'required-option-missing': [12, 1]
 }
 
 icmpv6_remove = []
 icmpv6_translations = {
     'ping': 'echo-request',
     'pong': 'echo-reply',
     # Destination Unreachable
     'no-route': [1, 0],
     'communication-prohibited': [1, 1],
     'address-unreachble': [1, 3],
     'port-unreachable': [1, 4],
     # nd
     'redirect': 'nd-redirect',
     'router-solicitation': 'nd-router-solicit',
     'router-advertisement': 'nd-router-advert',
     'neighbour-solicitation': 'nd-neighbor-solicit',
     'neighbor-solicitation': 'nd-neighbor-solicit',
     'neighbour-advertisement': 'nd-neighbor-advert',
     'neighbor-advertisement': 'nd-neighbor-advert',
     #  Time Exceeded
     'ttl-zero-during-transit': [3, 0],
     'ttl-zero-during-reassembly': [3, 1],
     # Parameter Problem
     'bad-header': [4, 0],
     'unknown-header-type': [4, 1],
     'unknown-option': [4, 2]
 }
 
-v4_found = False
-v6_found = False
 v4_groups = ["address-group", "network-group", "port-group"]
 v6_groups = ["ipv6-address-group", "ipv6-network-group", "port-group"]
-translated_dict = {}
 
-if config.exists(base + ['group']):
-    for group_type in config.list_nodes(base + ['group']):
-        for group_name in config.list_nodes(base + ['group', group_type]):
-            name_description = base + ['group', group_type, group_name, 'description']
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    v4_found = False
+    v6_found = False
+    translated_dict = {}
+
+    if config.exists(base + ['group']):
+        for group_type in config.list_nodes(base + ['group']):
+            for group_name in config.list_nodes(base + ['group', group_type]):
+                name_description = base + ['group', group_type, group_name, 'description']
+                if config.exists(name_description):
+                    tmp = config.return_value(name_description)
+                    config.set(name_description, value=tmp[:max_len_description])
+                if '+' in group_name:
+                    replacement_string = "_"
+                    if group_type in v4_groups and not v4_found:
+                        v4_found = True
+                    if group_type in v6_groups and not v6_found:
+                        v6_found = True
+                    new_group_name = group_name.replace('+', replacement_string)
+                    while config.exists(base + ['group', group_type, new_group_name]):
+                        replacement_string = replacement_string + "_"
+                        new_group_name = group_name.replace('+', replacement_string)
+                    translated_dict[group_name] = new_group_name
+                    config.copy(base + ['group', group_type, group_name], base + ['group', group_type, new_group_name])
+                    config.delete(base + ['group', group_type, group_name])
+
+    if config.exists(base + ['name']):
+        for name in config.list_nodes(base + ['name']):
+            name_description = base + ['name', name, 'description']
             if config.exists(name_description):
                 tmp = config.return_value(name_description)
                 config.set(name_description, value=tmp[:max_len_description])
-            if '+' in group_name:
-                replacement_string = "_"
-                if group_type in v4_groups and not v4_found:
-                    v4_found = True
-                if group_type in v6_groups and not v6_found:
-                    v6_found = True
-                new_group_name = group_name.replace('+', replacement_string)
-                while config.exists(base + ['group', group_type, new_group_name]):
-                    replacement_string = replacement_string + "_"
-                    new_group_name = group_name.replace('+', replacement_string)
-                translated_dict[group_name] = new_group_name
-                config.copy(base + ['group', group_type, group_name], base + ['group', group_type, new_group_name])
-                config.delete(base + ['group', group_type, group_name])
-
-if config.exists(base + ['name']):
-    for name in config.list_nodes(base + ['name']):
-        name_description = base + ['name', name, 'description']
-        if config.exists(name_description):
-            tmp = config.return_value(name_description)
-            config.set(name_description, value=tmp[:max_len_description])
-
-        if not config.exists(base + ['name', name, 'rule']):
-            continue
-
-        for rule in config.list_nodes(base + ['name', name, 'rule']):
-            rule_description = base + ['name', name, 'rule', rule, 'description']
-            if config.exists(rule_description):
-                tmp = config.return_value(rule_description)
-                config.set(rule_description, value=tmp[:max_len_description])
-
-            rule_recent = base + ['name', name, 'rule', rule, 'recent']
-            rule_time = base + ['name', name, 'rule', rule, 'time']
-            rule_tcp_flags = base + ['name', name, 'rule', rule, 'tcp', 'flags']
-            rule_icmp = base + ['name', name, 'rule', rule, 'icmp']
-
-            if config.exists(rule_time + ['monthdays']):
-                config.delete(rule_time + ['monthdays'])
-
-            if config.exists(rule_time + ['utc']):
-                config.delete(rule_time + ['utc'])
-
-            if config.exists(rule_recent + ['time']):
-                tmp = int(config.return_value(rule_recent + ['time']))
-                unit = 'minute'
-                if tmp > 600:
-                    unit = 'hour'
-                elif tmp < 10:
-                    unit = 'second'
-                config.set(rule_recent + ['time'], value=unit)
-
-            if config.exists(rule_tcp_flags):
-                tmp = config.return_value(rule_tcp_flags)
-                config.delete(rule_tcp_flags)
-                for flag in tmp.split(","):
-                    if flag[0] == '!':
-                        config.set(rule_tcp_flags + ['not', flag[1:].lower()])
-                    else:
-                        config.set(rule_tcp_flags + [flag.lower()])
-
-            if config.exists(rule_icmp + ['type-name']):
-                tmp = config.return_value(rule_icmp + ['type-name'])
-                if tmp in icmp_remove:
-                    config.delete(rule_icmp + ['type-name'])
-                elif tmp in icmp_translations:
-                    translate = icmp_translations[tmp]
-                    if isinstance(translate, str):
-                        config.set(rule_icmp + ['type-name'], value=translate)
-                    elif isinstance(translate, list):
+
+            if not config.exists(base + ['name', name, 'rule']):
+                continue
+
+            for rule in config.list_nodes(base + ['name', name, 'rule']):
+                rule_description = base + ['name', name, 'rule', rule, 'description']
+                if config.exists(rule_description):
+                    tmp = config.return_value(rule_description)
+                    config.set(rule_description, value=tmp[:max_len_description])
+
+                rule_recent = base + ['name', name, 'rule', rule, 'recent']
+                rule_time = base + ['name', name, 'rule', rule, 'time']
+                rule_tcp_flags = base + ['name', name, 'rule', rule, 'tcp', 'flags']
+                rule_icmp = base + ['name', name, 'rule', rule, 'icmp']
+
+                if config.exists(rule_time + ['monthdays']):
+                    config.delete(rule_time + ['monthdays'])
+
+                if config.exists(rule_time + ['utc']):
+                    config.delete(rule_time + ['utc'])
+
+                if config.exists(rule_recent + ['time']):
+                    tmp = int(config.return_value(rule_recent + ['time']))
+                    unit = 'minute'
+                    if tmp > 600:
+                        unit = 'hour'
+                    elif tmp < 10:
+                        unit = 'second'
+                    config.set(rule_recent + ['time'], value=unit)
+
+                if config.exists(rule_tcp_flags):
+                    tmp = config.return_value(rule_tcp_flags)
+                    config.delete(rule_tcp_flags)
+                    for flag in tmp.split(","):
+                        if flag[0] == '!':
+                            config.set(rule_tcp_flags + ['not', flag[1:].lower()])
+                        else:
+                            config.set(rule_tcp_flags + [flag.lower()])
+
+                if config.exists(rule_icmp + ['type-name']):
+                    tmp = config.return_value(rule_icmp + ['type-name'])
+                    if tmp in icmp_remove:
                         config.delete(rule_icmp + ['type-name'])
-                        config.set(rule_icmp + ['type'], value=translate[0])
-                        config.set(rule_icmp + ['code'], value=translate[1])
-
-            for direction in ['destination', 'source']:
-                if config.exists(base + ['name', name, 'rule', rule, direction]):
-                    if config.exists(base + ['name', name, 'rule', rule, direction, 'group']) and v4_found:
-                        for group_type in config.list_nodes(base + ['name', name, 'rule', rule, direction, 'group']):
-                            group_name = config.return_value(base + ['name', name, 'rule', rule, direction, 'group', group_type])
-                            if '+' in group_name:
-                                if group_name[0] == "!":
-                                    new_group_name = "!" + translated_dict[group_name[1:]]
-                                else:
-                                    new_group_name = translated_dict[group_name]
-                                config.set(base + ['name', name, 'rule', rule, direction, 'group', group_type], value=new_group_name)
-
-                    pg_base = base + ['name', name, 'rule', rule, direction, 'group', 'port-group']
-                    proto_base = base + ['name', name, 'rule', rule, 'protocol']
-                    if config.exists(pg_base) and not config.exists(proto_base):
-                        config.set(proto_base, value='tcp_udp')
-
-        if '+' in name:
-            replacement_string = "_"
-            new_name = name.replace('+', replacement_string)
-            while config.exists(base + ['name', new_name]):
-                replacement_string = replacement_string + "_"
+                    elif tmp in icmp_translations:
+                        translate = icmp_translations[tmp]
+                        if isinstance(translate, str):
+                            config.set(rule_icmp + ['type-name'], value=translate)
+                        elif isinstance(translate, list):
+                            config.delete(rule_icmp + ['type-name'])
+                            config.set(rule_icmp + ['type'], value=translate[0])
+                            config.set(rule_icmp + ['code'], value=translate[1])
+
+                for direction in ['destination', 'source']:
+                    if config.exists(base + ['name', name, 'rule', rule, direction]):
+                        if config.exists(base + ['name', name, 'rule', rule, direction, 'group']) and v4_found:
+                            for group_type in config.list_nodes(base + ['name', name, 'rule', rule, direction, 'group']):
+                                group_name = config.return_value(base + ['name', name, 'rule', rule, direction, 'group', group_type])
+                                if '+' in group_name:
+                                    if group_name[0] == "!":
+                                        new_group_name = "!" + translated_dict[group_name[1:]]
+                                    else:
+                                        new_group_name = translated_dict[group_name]
+                                    config.set(base + ['name', name, 'rule', rule, direction, 'group', group_type], value=new_group_name)
+
+                        pg_base = base + ['name', name, 'rule', rule, direction, 'group', 'port-group']
+                        proto_base = base + ['name', name, 'rule', rule, 'protocol']
+                        if config.exists(pg_base) and not config.exists(proto_base):
+                            config.set(proto_base, value='tcp_udp')
+
+            if '+' in name:
+                replacement_string = "_"
                 new_name = name.replace('+', replacement_string)
-            config.copy(base + ['name', name], base + ['name', new_name])
-            config.delete(base + ['name', name])
-
-if config.exists(base + ['ipv6-name']):
-    for name in config.list_nodes(base + ['ipv6-name']):
-        name_description = base + ['ipv6-name', name, 'description']
-        if config.exists(name_description):
-            tmp = config.return_value(name_description)
-            config.set(name_description, value=tmp[:max_len_description])
-
-        if not config.exists(base + ['ipv6-name', name, 'rule']):
-            continue
-
-        for rule in config.list_nodes(base + ['ipv6-name', name, 'rule']):
-            rule_description = base + ['ipv6-name', name, 'rule', rule, 'description']
-            if config.exists(rule_description):
-                tmp = config.return_value(rule_description)
-                config.set(rule_description, value=tmp[:max_len_description])
-
-            rule_recent = base + ['ipv6-name', name, 'rule', rule, 'recent']
-            rule_time = base + ['ipv6-name', name, 'rule', rule, 'time']
-            rule_tcp_flags = base + ['ipv6-name', name, 'rule', rule, 'tcp', 'flags']
-            rule_icmp = base + ['ipv6-name', name, 'rule', rule, 'icmpv6']
-
-            if config.exists(rule_time + ['monthdays']):
-                config.delete(rule_time + ['monthdays'])
-
-            if config.exists(rule_time + ['utc']):
-                config.delete(rule_time + ['utc'])
-
-            if config.exists(rule_recent + ['time']):
-                tmp = int(config.return_value(rule_recent + ['time']))
-                unit = 'minute'
-                if tmp > 600:
-                    unit = 'hour'
-                elif tmp < 10:
-                    unit = 'second'
-                config.set(rule_recent + ['time'], value=unit)
-
-            if config.exists(rule_tcp_flags):
-                tmp = config.return_value(rule_tcp_flags)
-                config.delete(rule_tcp_flags)
-                for flag in tmp.split(","):
-                    if flag[0] == '!':
-                        config.set(rule_tcp_flags + ['not', flag[1:].lower()])
-                    else:
-                        config.set(rule_tcp_flags + [flag.lower()])
-
-            if config.exists(base + ['ipv6-name', name, 'rule', rule, 'protocol']):
-                tmp = config.return_value(base + ['ipv6-name', name, 'rule', rule, 'protocol'])
-                if tmp == 'icmpv6':
-                    config.set(base + ['ipv6-name', name, 'rule', rule, 'protocol'], value='ipv6-icmp')
-
-            if config.exists(rule_icmp + ['type']):
-                tmp = config.return_value(rule_icmp + ['type'])
-                type_code_match = re.match(r'^(\d+)(?:/(\d+))?$', tmp)
-
-                if type_code_match:
-                    config.set(rule_icmp + ['type'], value=type_code_match[1])
-                    if type_code_match[2]:
-                        config.set(rule_icmp + ['code'], value=type_code_match[2])
-                elif tmp in icmpv6_remove:
-                    config.delete(rule_icmp + ['type'])
-                elif tmp in icmpv6_translations:
-                    translate = icmpv6_translations[tmp]
-                    if isinstance(translate, str):
+                while config.exists(base + ['name', new_name]):
+                    replacement_string = replacement_string + "_"
+                    new_name = name.replace('+', replacement_string)
+                config.copy(base + ['name', name], base + ['name', new_name])
+                config.delete(base + ['name', name])
+
+    if config.exists(base + ['ipv6-name']):
+        for name in config.list_nodes(base + ['ipv6-name']):
+            name_description = base + ['ipv6-name', name, 'description']
+            if config.exists(name_description):
+                tmp = config.return_value(name_description)
+                config.set(name_description, value=tmp[:max_len_description])
+
+            if not config.exists(base + ['ipv6-name', name, 'rule']):
+                continue
+
+            for rule in config.list_nodes(base + ['ipv6-name', name, 'rule']):
+                rule_description = base + ['ipv6-name', name, 'rule', rule, 'description']
+                if config.exists(rule_description):
+                    tmp = config.return_value(rule_description)
+                    config.set(rule_description, value=tmp[:max_len_description])
+
+                rule_recent = base + ['ipv6-name', name, 'rule', rule, 'recent']
+                rule_time = base + ['ipv6-name', name, 'rule', rule, 'time']
+                rule_tcp_flags = base + ['ipv6-name', name, 'rule', rule, 'tcp', 'flags']
+                rule_icmp = base + ['ipv6-name', name, 'rule', rule, 'icmpv6']
+
+                if config.exists(rule_time + ['monthdays']):
+                    config.delete(rule_time + ['monthdays'])
+
+                if config.exists(rule_time + ['utc']):
+                    config.delete(rule_time + ['utc'])
+
+                if config.exists(rule_recent + ['time']):
+                    tmp = int(config.return_value(rule_recent + ['time']))
+                    unit = 'minute'
+                    if tmp > 600:
+                        unit = 'hour'
+                    elif tmp < 10:
+                        unit = 'second'
+                    config.set(rule_recent + ['time'], value=unit)
+
+                if config.exists(rule_tcp_flags):
+                    tmp = config.return_value(rule_tcp_flags)
+                    config.delete(rule_tcp_flags)
+                    for flag in tmp.split(","):
+                        if flag[0] == '!':
+                            config.set(rule_tcp_flags + ['not', flag[1:].lower()])
+                        else:
+                            config.set(rule_tcp_flags + [flag.lower()])
+
+                if config.exists(base + ['ipv6-name', name, 'rule', rule, 'protocol']):
+                    tmp = config.return_value(base + ['ipv6-name', name, 'rule', rule, 'protocol'])
+                    if tmp == 'icmpv6':
+                        config.set(base + ['ipv6-name', name, 'rule', rule, 'protocol'], value='ipv6-icmp')
+
+                if config.exists(rule_icmp + ['type']):
+                    tmp = config.return_value(rule_icmp + ['type'])
+                    type_code_match = re.match(r'^(\d+)(?:/(\d+))?$', tmp)
+
+                    if type_code_match:
+                        config.set(rule_icmp + ['type'], value=type_code_match[1])
+                        if type_code_match[2]:
+                            config.set(rule_icmp + ['code'], value=type_code_match[2])
+                    elif tmp in icmpv6_remove:
                         config.delete(rule_icmp + ['type'])
-                        config.set(rule_icmp + ['type-name'], value=translate)
-                    elif isinstance(translate, list):
-                        config.set(rule_icmp + ['type'], value=translate[0])
-                        config.set(rule_icmp + ['code'], value=translate[1])
-                else:
-                    config.rename(rule_icmp + ['type'], 'type-name')
-
-            for direction in ['destination', 'source']:
-                if config.exists(base + ['ipv6-name', name, 'rule', rule, direction]):
-                    if config.exists(base + ['ipv6-name', name, 'rule', rule, direction, 'group']) and v6_found:
-                        for group_type in config.list_nodes(base + ['ipv6-name', name, 'rule', rule, direction, 'group']):
-                            group_name = config.return_value(base + ['ipv6-name', name, 'rule', rule, direction, 'group', group_type])
-                            if '+' in group_name:
-                                if group_name[0] == "!":
-                                    new_group_name = "!" + translated_dict[group_name[1:]]
-                                else:
-                                    new_group_name = translated_dict[group_name]
-                                config.set(base + ['ipv6-name', name, 'rule', rule, direction, 'group', group_type], value=new_group_name)
-
-                    pg_base = base + ['ipv6-name', name, 'rule', rule, direction, 'group', 'port-group']
-                    proto_base = base + ['ipv6-name', name, 'rule', rule, 'protocol']
-                    if config.exists(pg_base) and not config.exists(proto_base):
-                        config.set(proto_base, value='tcp_udp')
-
-        if '+' in name:
-            replacement_string = "_"
-            new_name = name.replace('+', replacement_string)
-            while config.exists(base + ['ipv6-name', new_name]):
-                replacement_string = replacement_string + "_"
+                    elif tmp in icmpv6_translations:
+                        translate = icmpv6_translations[tmp]
+                        if isinstance(translate, str):
+                            config.delete(rule_icmp + ['type'])
+                            config.set(rule_icmp + ['type-name'], value=translate)
+                        elif isinstance(translate, list):
+                            config.set(rule_icmp + ['type'], value=translate[0])
+                            config.set(rule_icmp + ['code'], value=translate[1])
+                    else:
+                        config.rename(rule_icmp + ['type'], 'type-name')
+
+                for direction in ['destination', 'source']:
+                    if config.exists(base + ['ipv6-name', name, 'rule', rule, direction]):
+                        if config.exists(base + ['ipv6-name', name, 'rule', rule, direction, 'group']) and v6_found:
+                            for group_type in config.list_nodes(base + ['ipv6-name', name, 'rule', rule, direction, 'group']):
+                                group_name = config.return_value(base + ['ipv6-name', name, 'rule', rule, direction, 'group', group_type])
+                                if '+' in group_name:
+                                    if group_name[0] == "!":
+                                        new_group_name = "!" + translated_dict[group_name[1:]]
+                                    else:
+                                        new_group_name = translated_dict[group_name]
+                                    config.set(base + ['ipv6-name', name, 'rule', rule, direction, 'group', group_type], value=new_group_name)
+
+                        pg_base = base + ['ipv6-name', name, 'rule', rule, direction, 'group', 'port-group']
+                        proto_base = base + ['ipv6-name', name, 'rule', rule, 'protocol']
+                        if config.exists(pg_base) and not config.exists(proto_base):
+                            config.set(proto_base, value='tcp_udp')
+
+            if '+' in name:
+                replacement_string = "_"
                 new_name = name.replace('+', replacement_string)
-            config.copy(base + ['ipv6-name', name], base + ['ipv6-name', new_name])
-            config.delete(base + ['ipv6-name', name])
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+                while config.exists(base + ['ipv6-name', new_name]):
+                    replacement_string = replacement_string + "_"
+                    new_name = name.replace('+', replacement_string)
+                config.copy(base + ['ipv6-name', name], base + ['ipv6-name', new_name])
+                config.delete(base + ['ipv6-name', name])
diff --git a/src/migration-scripts/firewall/7-to-8 b/src/migration-scripts/firewall/7-to-8
old mode 100755
new mode 100644
index 17af0f355..b8bcc52cc
--- a/src/migration-scripts/firewall/7-to-8
+++ b/src/migration-scripts/firewall/7-to-8
@@ -1,101 +1,81 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T2199: Migrate interface firewall nodes to firewall interfaces <ifname> <direction> name/ipv6-name <name>
 # T2199: Migrate zone-policy to firewall node
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['firewall']
 zone_base = ['zone-policy']
-config = ConfigTree(config_file)
-
-if not config.exists(base) and not config.exists(zone_base):
-    # Nothing to do
-    exit(0)
 
 def migrate_interface(config, iftype, ifname, vif=None, vifs=None, vifc=None):
     if_path = ['interfaces', iftype, ifname]
     ifname_full = ifname
 
     if vif:
         if_path += ['vif', vif]
         ifname_full = f'{ifname}.{vif}'
     elif vifs:
         if_path += ['vif-s', vifs]
         ifname_full = f'{ifname}.{vifs}'
         if vifc:
             if_path += ['vif-c', vifc]
             ifname_full = f'{ifname}.{vifs}.{vifc}'
 
     if not config.exists(if_path + ['firewall']):
         return
 
     if not config.exists(['firewall', 'interface']):
         config.set(['firewall', 'interface'])
         config.set_tag(['firewall', 'interface'])
 
     config.copy(if_path + ['firewall'], ['firewall', 'interface', ifname_full])
     config.delete(if_path + ['firewall'])
 
-for iftype in config.list_nodes(['interfaces']):
-    for ifname in config.list_nodes(['interfaces', iftype]):
-        migrate_interface(config, iftype, ifname)
-
-        if config.exists(['interfaces', iftype, ifname, 'vif']):
-            for vif in config.list_nodes(['interfaces', iftype, ifname, 'vif']):
-                migrate_interface(config, iftype, ifname, vif=vif)
-
-        if config.exists(['interfaces', iftype, ifname, 'vif-s']):
-            for vifs in config.list_nodes(['interfaces', iftype, ifname, 'vif-s']):
-                migrate_interface(config, iftype, ifname, vifs=vifs)
-
-                if config.exists(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
-                    for vifc in config.list_nodes(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
-                        migrate_interface(config, iftype, ifname, vifs=vifs, vifc=vifc)
-
-if config.exists(zone_base + ['zone']):
-    config.set(['firewall', 'zone'])
-    config.set_tag(['firewall', 'zone'])
-
-    for zone in config.list_nodes(zone_base + ['zone']):
-        if 'interface' in config.list_nodes(zone_base + ['zone', zone]):
-            for iface in config.return_values(zone_base + ['zone', zone, 'interface']):
-                if '+' in iface:
-                    config.delete_value(zone_base + ['zone', zone, 'interface'], value=iface)
-                    iface = iface.replace('+', '*')
-                    config.set(zone_base + ['zone', zone, 'interface'], value=iface, replace=False)
-        config.copy(zone_base + ['zone', zone], ['firewall', 'zone', zone])
-    config.delete(zone_base)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base) and not config.exists(zone_base):
+        # Nothing to do
+        return
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    for iftype in config.list_nodes(['interfaces']):
+        for ifname in config.list_nodes(['interfaces', iftype]):
+            migrate_interface(config, iftype, ifname)
+
+            if config.exists(['interfaces', iftype, ifname, 'vif']):
+                for vif in config.list_nodes(['interfaces', iftype, ifname, 'vif']):
+                    migrate_interface(config, iftype, ifname, vif=vif)
+
+            if config.exists(['interfaces', iftype, ifname, 'vif-s']):
+                for vifs in config.list_nodes(['interfaces', iftype, ifname, 'vif-s']):
+                    migrate_interface(config, iftype, ifname, vifs=vifs)
+
+                    if config.exists(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
+                        for vifc in config.list_nodes(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
+                            migrate_interface(config, iftype, ifname, vifs=vifs, vifc=vifc)
+
+    if config.exists(zone_base + ['zone']):
+        config.set(['firewall', 'zone'])
+        config.set_tag(['firewall', 'zone'])
+
+        for zone in config.list_nodes(zone_base + ['zone']):
+            if 'interface' in config.list_nodes(zone_base + ['zone', zone]):
+                for iface in config.return_values(zone_base + ['zone', zone, 'interface']):
+                    if '+' in iface:
+                        config.delete_value(zone_base + ['zone', zone, 'interface'], value=iface)
+                        iface = iface.replace('+', '*')
+                        config.set(zone_base + ['zone', zone, 'interface'], value=iface, replace=False)
+            config.copy(zone_base + ['zone', zone], ['firewall', 'zone', zone])
+        config.delete(zone_base)
\ No newline at end of file
diff --git a/src/migration-scripts/firewall/8-to-9 b/src/migration-scripts/firewall/8-to-9
old mode 100755
new mode 100644
index 6e019beb2..3c9e84662
--- a/src/migration-scripts/firewall/8-to-9
+++ b/src/migration-scripts/firewall/8-to-9
@@ -1,88 +1,68 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T4780: Add firewall interface group
 #  cli changes from:
 #  set firewall [name | ipv6-name] <name> rule <number> [inbound-interface | outbound-interface] <interface_name>
 #  To
 #  set firewall [name | ipv6-name] <name> rule <number> [inbound-interface | outbound-interface]  [interface-name | interface-group] <interface_name | interface_group>
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['firewall']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-if config.exists(base + ['name']):
-    for name in config.list_nodes(base + ['name']):
-        if not config.exists(base + ['name', name, 'rule']):
-            continue
-
-        for rule in config.list_nodes(base + ['name', name, 'rule']):
-            rule_iiface = base + ['name', name, 'rule', rule, 'inbound-interface']
-            rule_oiface = base + ['name', name, 'rule', rule, 'outbound-interface']
-
-            if config.exists(rule_iiface):
-                tmp = config.return_value(rule_iiface)
-                config.delete(rule_iiface)
-                config.set(rule_iiface + ['interface-name'], value=tmp)
-
-            if config.exists(rule_oiface):
-                tmp = config.return_value(rule_oiface)
-                config.delete(rule_oiface)
-                config.set(rule_oiface + ['interface-name'], value=tmp)
-
-
-if config.exists(base + ['ipv6-name']):
-    for name in config.list_nodes(base + ['ipv6-name']):
-        if not config.exists(base + ['ipv6-name', name, 'rule']):
-            continue
-
-        for rule in config.list_nodes(base + ['ipv6-name', name, 'rule']):
-            rule_iiface = base + ['ipv6-name', name, 'rule', rule, 'inbound-interface']
-            rule_oiface = base + ['ipv6-name', name, 'rule', rule, 'outbound-interface']
-
-            if config.exists(rule_iiface):
-                tmp = config.return_value(rule_iiface)
-                config.delete(rule_iiface)
-                config.set(rule_iiface + ['interface-name'], value=tmp)
-
-            if config.exists(rule_oiface):
-                tmp = config.return_value(rule_oiface)
-                config.delete(rule_oiface)
-                config.set(rule_oiface + ['interface-name'], value=tmp)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    if config.exists(base + ['name']):
+        for name in config.list_nodes(base + ['name']):
+            if not config.exists(base + ['name', name, 'rule']):
+                continue
+
+            for rule in config.list_nodes(base + ['name', name, 'rule']):
+                rule_iiface = base + ['name', name, 'rule', rule, 'inbound-interface']
+                rule_oiface = base + ['name', name, 'rule', rule, 'outbound-interface']
+
+                if config.exists(rule_iiface):
+                    tmp = config.return_value(rule_iiface)
+                    config.delete(rule_iiface)
+                    config.set(rule_iiface + ['interface-name'], value=tmp)
+
+                if config.exists(rule_oiface):
+                    tmp = config.return_value(rule_oiface)
+                    config.delete(rule_oiface)
+                    config.set(rule_oiface + ['interface-name'], value=tmp)
+
+
+    if config.exists(base + ['ipv6-name']):
+        for name in config.list_nodes(base + ['ipv6-name']):
+            if not config.exists(base + ['ipv6-name', name, 'rule']):
+                continue
+
+            for rule in config.list_nodes(base + ['ipv6-name', name, 'rule']):
+                rule_iiface = base + ['ipv6-name', name, 'rule', rule, 'inbound-interface']
+                rule_oiface = base + ['ipv6-name', name, 'rule', rule, 'outbound-interface']
+
+                if config.exists(rule_iiface):
+                    tmp = config.return_value(rule_iiface)
+                    config.delete(rule_iiface)
+                    config.set(rule_iiface + ['interface-name'], value=tmp)
+
+                if config.exists(rule_oiface):
+                    tmp = config.return_value(rule_oiface)
+                    config.delete(rule_oiface)
+                    config.set(rule_oiface + ['interface-name'], value=tmp)
diff --git a/src/migration-scripts/firewall/9-to-10 b/src/migration-scripts/firewall/9-to-10
old mode 100755
new mode 100644
index ce509a731..306a53a86
--- a/src/migration-scripts/firewall/9-to-10
+++ b/src/migration-scripts/firewall/9-to-10
@@ -1,77 +1,57 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5050: Log options
 #  cli changes from:
 #  set firewall [name | ipv6-name] <name> rule <number> log-level <log_level>
 #  To
 #  set firewall [name | ipv6-name] <name> rule <number> log-options level <log_level>
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['firewall']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-if config.exists(base + ['name']):
-    for name in config.list_nodes(base + ['name']):
-        if not config.exists(base + ['name', name, 'rule']):
-            continue
-
-        for rule in config.list_nodes(base + ['name', name, 'rule']):
-            log_options_base = base + ['name', name, 'rule', rule, 'log-options']
-            rule_log_level = base + ['name', name, 'rule', rule, 'log-level']
-
-            if config.exists(rule_log_level):
-                tmp = config.return_value(rule_log_level)
-                config.delete(rule_log_level)
-                config.set(log_options_base + ['level'], value=tmp)
-
-if config.exists(base + ['ipv6-name']):
-    for name in config.list_nodes(base + ['ipv6-name']):
-        if not config.exists(base + ['ipv6-name', name, 'rule']):
-            continue
-
-        for rule in config.list_nodes(base + ['ipv6-name', name, 'rule']):
-            log_options_base = base + ['ipv6-name', name, 'rule', rule, 'log-options']
-            rule_log_level = base + ['ipv6-name', name, 'rule', rule, 'log-level']
-
-            if config.exists(rule_log_level):
-                tmp = config.return_value(rule_log_level)
-                config.delete(rule_log_level)
-                config.set(log_options_base + ['level'], value=tmp)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    if config.exists(base + ['name']):
+        for name in config.list_nodes(base + ['name']):
+            if not config.exists(base + ['name', name, 'rule']):
+                continue
+
+            for rule in config.list_nodes(base + ['name', name, 'rule']):
+                log_options_base = base + ['name', name, 'rule', rule, 'log-options']
+                rule_log_level = base + ['name', name, 'rule', rule, 'log-level']
+
+                if config.exists(rule_log_level):
+                    tmp = config.return_value(rule_log_level)
+                    config.delete(rule_log_level)
+                    config.set(log_options_base + ['level'], value=tmp)
+
+    if config.exists(base + ['ipv6-name']):
+        for name in config.list_nodes(base + ['ipv6-name']):
+            if not config.exists(base + ['ipv6-name', name, 'rule']):
+                continue
+
+            for rule in config.list_nodes(base + ['ipv6-name', name, 'rule']):
+                log_options_base = base + ['ipv6-name', name, 'rule', rule, 'log-options']
+                rule_log_level = base + ['ipv6-name', name, 'rule', rule, 'log-level']
+
+                if config.exists(rule_log_level):
+                    tmp = config.return_value(rule_log_level)
+                    config.delete(rule_log_level)
+                    config.set(log_options_base + ['level'], value=tmp)
diff --git a/src/migration-scripts/flow-accounting/0-to-1 b/src/migration-scripts/flow-accounting/0-to-1
old mode 100755
new mode 100644
index 0f790fd9c..77670e3ef
--- a/src/migration-scripts/flow-accounting/0-to-1
+++ b/src/migration-scripts/flow-accounting/0-to-1
@@ -1,69 +1,51 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T4099: flow-accounting: sync "source-ip" and "source-address" between netflow
 #        and sflow ion CLI
 # T4105: flow-accounting: drop "sflow agent-address auto"
 
-from sys import argv
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['system', 'flow-accounting']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-# T4099
-tmp = base + ['netflow', 'source-ip']
-if config.exists(tmp):
-    config.rename(tmp, 'source-address')
-
-# T4105
-tmp = base + ['sflow', 'agent-address']
-if config.exists(tmp):
-    value = config.return_value(tmp)
-    if value == 'auto':
-        # delete the "auto"
-        config.delete(tmp)
-
-        # 1) check if BGP router-id is set
-        # 2) check if OSPF router-id is set
-        # 3) check if OSPFv3 router-id is set
-        router_id = None
-        for protocol in ['bgp', 'ospf', 'ospfv3']:
-            if config.exists(['protocols', protocol, 'parameters', 'router-id']):
-                router_id = config.return_value(['protocols', protocol, 'parameters', 'router-id'])
-                break
-        if router_id:
-            config.set(tmp, value=router_id)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    # T4099
+    tmp = base + ['netflow', 'source-ip']
+    if config.exists(tmp):
+        config.rename(tmp, 'source-address')
+
+    # T4105
+    tmp = base + ['sflow', 'agent-address']
+    if config.exists(tmp):
+        value = config.return_value(tmp)
+        if value == 'auto':
+            # delete the "auto"
+            config.delete(tmp)
+
+            # 1) check if BGP router-id is set
+            # 2) check if OSPF router-id is set
+            # 3) check if OSPFv3 router-id is set
+            router_id = None
+            for protocol in ['bgp', 'ospf', 'ospfv3']:
+                if config.exists(['protocols', protocol, 'parameters', 'router-id']):
+                    router_id = config.return_value(['protocols', protocol, 'parameters', 'router-id'])
+                    break
+            if router_id:
+                config.set(tmp, value=router_id)
diff --git a/src/migration-scripts/https/0-to-1 b/src/migration-scripts/https/0-to-1
old mode 100755
new mode 100644
index 23809f5ad..52fe3f2ad
--- a/src/migration-scripts/https/0-to-1
+++ b/src/migration-scripts/https/0-to-1
@@ -1,69 +1,50 @@
-#!/usr/bin/env python3
+# Copyright 202-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # * Move server block directives under 'virtual-host' tag node, instead of
 #   relying on 'listen-address' tag node
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if (len(sys.argv) < 2):
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
+old_base = ['service', 'https', 'listen-address']
 
-config = ConfigTree(config_file)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(old_base):
+        # Nothing to do
+        return
 
-old_base = ['service', 'https', 'listen-address']
-if not config.exists(old_base):
-    # Nothing to do
-    sys.exit(0)
-else:
     new_base = ['service', 'https', 'virtual-host']
     config.set(new_base)
     config.set_tag(new_base)
 
     index = 0
     for addr in config.list_nodes(old_base):
         tag_name = f'vhost{index}'
         config.set(new_base + [tag_name])
         config.set(new_base + [tag_name, 'listen-address'], value=addr)
 
         if config.exists(old_base + [addr, 'listen-port']):
             port = config.return_value(old_base + [addr, 'listen-port'])
             config.set(new_base + [tag_name, 'listen-port'], value=port)
 
         if config.exists(old_base + [addr, 'server-name']):
             names = config.return_values(old_base + [addr, 'server-name'])
             for name in names:
                 config.set(new_base + [tag_name, 'server-name'], value=name,
                            replace=False)
 
         index += 1
 
     config.delete(old_base)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/https/1-to-2 b/src/migration-scripts/https/1-to-2
old mode 100755
new mode 100644
index 1a2cdc1e7..dad7ac1f0
--- a/src/migration-scripts/https/1-to-2
+++ b/src/migration-scripts/https/1-to-2
@@ -1,54 +1,35 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # * Move 'api virtual-host' list to 'api-restrict virtual-host' so it
 #   is owned by service_https.py
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if (len(sys.argv) < 2):
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
+old_base = ['service', 'https', 'api', 'virtual-host']
 
-config = ConfigTree(config_file)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(old_base):
+        # Nothing to do
+        return
 
-old_base = ['service', 'https', 'api', 'virtual-host']
-if not config.exists(old_base):
-    # Nothing to do
-    sys.exit(0)
-else:
     new_base = ['service', 'https', 'api-restrict', 'virtual-host']
     config.set(new_base)
 
     names = config.return_values(old_base)
     for name in names:
         config.set(new_base, value=name, replace=False)
 
     config.delete(old_base)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/https/2-to-3 b/src/migration-scripts/https/2-to-3
old mode 100755
new mode 100644
index 2beba6d2b..1125caebf
--- a/src/migration-scripts/https/2-to-3
+++ b/src/migration-scripts/https/2-to-3
@@ -1,86 +1,66 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # * Migrate system signed certificate to use PKI
 
-import sys
-
 from vyos.configtree import ConfigTree
 from vyos.pki import create_certificate
 from vyos.pki import create_certificate_request
 from vyos.pki import create_private_key
 from vyos.pki import encode_certificate
 from vyos.pki import encode_private_key
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 base = ['service', 'https', 'certificates']
 pki_base = ['pki']
 
-if not config.exists(base + ['system-generated-certificate']):
-    sys.exit(0)
-
 def wrapped_pem_to_config_value(pem):
     out = []
     for line in pem.strip().split("\n"):
         if not line or line.startswith("-----") or line[0] == '#':
             continue
         out.append(line)
     return "".join(out)
 
-if not config.exists(pki_base + ['certificate']):
-    config.set(pki_base + ['certificate'])
-    config.set_tag(pki_base + ['certificate'])
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base + ['system-generated-certificate']):
+        return
 
-valid_days = 365
-if config.exists(base + ['system-generated-certificate', 'lifetime']):
-    valid_days = int(config.return_value(base + ['system-generated-certificate', 'lifetime']))
+    if not config.exists(pki_base + ['certificate']):
+        config.set(pki_base + ['certificate'])
+        config.set_tag(pki_base + ['certificate'])
 
-key = create_private_key('rsa', 2048)
-subject = {'country': 'GB', 'state': 'N/A', 'locality': 'N/A', 'organization': 'VyOS', 'common_name': 'vyos'}
-cert_req = create_certificate_request(subject, key, ['vyos'])
-cert = create_certificate(cert_req, cert_req, key, valid_days)
+    valid_days = 365
+    if config.exists(base + ['system-generated-certificate', 'lifetime']):
+        valid_days = int(config.return_value(base + ['system-generated-certificate', 'lifetime']))
 
-if cert:
-    cert_pem = encode_certificate(cert)
-    config.set(pki_base + ['certificate', 'generated_https', 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+    key = create_private_key('rsa', 2048)
+    subject = {'country': 'GB', 'state': 'N/A', 'locality': 'N/A', 'organization': 'VyOS', 'common_name': 'vyos'}
+    cert_req = create_certificate_request(subject, key, ['vyos'])
+    cert = create_certificate(cert_req, cert_req, key, valid_days)
 
-if key:
-    key_pem = encode_private_key(key)
-    config.set(pki_base + ['certificate', 'generated_https', 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
+    if cert:
+        cert_pem = encode_certificate(cert)
+        config.set(pki_base + ['certificate', 'generated_https', 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
 
-if cert and key:
-    config.set(base + ['certificate'], value='generated_https')
-else:
-    print('Failed to migrate system-generated-certificate from https service')
+    if key:
+        key_pem = encode_private_key(key)
+        config.set(pki_base + ['certificate', 'generated_https', 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
 
-config.delete(base + ['system-generated-certificate'])
+    if cert and key:
+        config.set(base + ['certificate'], value='generated_https')
+    else:
+        print('Failed to migrate system-generated-certificate from https service')
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+    config.delete(base + ['system-generated-certificate'])
diff --git a/src/migration-scripts/https/3-to-4 b/src/migration-scripts/https/3-to-4
old mode 100755
new mode 100644
index b3cfca201..c01236cc6
--- a/src/migration-scripts/https/3-to-4
+++ b/src/migration-scripts/https/3-to-4
@@ -1,53 +1,34 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T4768 rename node 'gql' to 'graphql'.
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 old_base = ['service', 'https', 'api', 'gql']
-if not config.exists(old_base):
-    # Nothing to do
-    sys.exit(0)
-
 new_base = ['service', 'https', 'api', 'graphql']
-config.set(new_base)
 
-nodes = config.list_nodes(old_base)
-for node in nodes:
-    config.copy(old_base + [node], new_base + [node])
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(old_base):
+        # Nothing to do
+        return
+
+    config.set(new_base)
 
-config.delete(old_base)
+    nodes = config.list_nodes(old_base)
+    for node in nodes:
+        config.copy(old_base + [node], new_base + [node])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+    config.delete(old_base)
diff --git a/src/migration-scripts/https/4-to-5 b/src/migration-scripts/https/4-to-5
old mode 100755
new mode 100644
index 0dfb6ac19..0f1c7901f
--- a/src/migration-scripts/https/4-to-5
+++ b/src/migration-scripts/https/4-to-5
@@ -1,62 +1,43 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5762: http: api: smoketests fail as they can not establish IPv6 connection
 #        to uvicorn backend server, always make the UNIX domain socket the
 #        default way of communication
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 base = ['service', 'https']
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
-
-# Delete "socket" CLI option - we always use UNIX domain sockets for
-# NGINX <-> API server communication
-if config.exists(base + ['api', 'socket']):
-    config.delete(base + ['api', 'socket'])
-
-# There is no need for an API service port, as UNIX domain sockets
-# are used
-if config.exists(base + ['api', 'port']):
-    config.delete(base + ['api', 'port'])
-
-# rename listen-port -> port ver virtual-host
-if config.exists(base + ['virtual-host']):
-    for vhost in config.list_nodes(base + ['virtual-host']):
-        if config.exists(base + ['virtual-host', vhost, 'listen-port']):
-            config.rename(base + ['virtual-host', vhost, 'listen-port'], 'port')
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    # Delete "socket" CLI option - we always use UNIX domain sockets for
+    # NGINX <-> API server communication
+    if config.exists(base + ['api', 'socket']):
+        config.delete(base + ['api', 'socket'])
+
+    # There is no need for an API service port, as UNIX domain sockets
+    # are used
+    if config.exists(base + ['api', 'port']):
+        config.delete(base + ['api', 'port'])
+
+    # rename listen-port -> port ver virtual-host
+    if config.exists(base + ['virtual-host']):
+        for vhost in config.list_nodes(base + ['virtual-host']):
+            if config.exists(base + ['virtual-host', vhost, 'listen-port']):
+                config.rename(base + ['virtual-host', vhost, 'listen-port'], 'port')
diff --git a/src/migration-scripts/https/5-to-6 b/src/migration-scripts/https/5-to-6
old mode 100755
new mode 100644
index 72e9e31f7..6ef6976b6
--- a/src/migration-scripts/https/5-to-6
+++ b/src/migration-scripts/https/5-to-6
@@ -1,107 +1,89 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5886: Add support for ACME protocol (LetsEncrypt), migrate https certbot
 #        to new "pki certificate" CLI tree
 # T5902: Remove virtual-host
 
 import os
-import sys
 
 from vyos.configtree import ConfigTree
 from vyos.defaults import directories
 from vyos.utils.process import cmd
 
 vyos_certbot_dir = directories['certbot']
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 base = ['service', 'https']
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
-
-if config.exists(base + ['certificates', 'certbot']):
-    # both domain-name and email must be set on CLI - ensured by previous verify()
-    domain_names = config.return_values(base + ['certificates', 'certbot', 'domain-name'])
-    email = config.return_value(base + ['certificates', 'certbot', 'email'])
-    config.delete(base + ['certificates', 'certbot'])
-
-    # Set default certname based on domain-name
-    cert_name = 'https-' + domain_names[0].split('.')[0]
-    # Overwrite certname from previous certbot calls if available
-    # We can not use python code like os.scandir due to filesystem permissions.
-    # This must be run as root
-    certbot_live = f'{vyos_certbot_dir}/live/' # we need the trailing /
-    if os.path.exists(certbot_live):
-        tmp = cmd(f'sudo find {certbot_live} -maxdepth 1 -type d')
-        tmp = tmp.split() # tmp = ['/config/auth/letsencrypt/live', '/config/auth/letsencrypt/live/router.vyos.net']
-        tmp.remove(certbot_live)
-        cert_name = tmp[0].replace(certbot_live, '')
 
-    config.set(['pki', 'certificate', cert_name, 'acme', 'email'], value=email)
-    config.set_tag(['pki', 'certificate'])
-    for domain in domain_names:
-        config.set(['pki', 'certificate', cert_name, 'acme', 'domain-name'], value=domain, replace=False)
-
-    # Update Webserver certificate
-    config.set(base + ['certificates', 'certificate'], value=cert_name)
-
-if config.exists(base + ['virtual-host']):
-    allow_client = []
-    listen_port = []
-    listen_address = []
-    for virtual_host in config.list_nodes(base + ['virtual-host']):
-        allow_path = base + ['virtual-host', virtual_host, 'allow-client', 'address']
-        if config.exists(allow_path):
-            tmp = config.return_values(allow_path)
-            allow_client.extend(tmp)
-
-        port_path = base + ['virtual-host', virtual_host, 'port']
-        if config.exists(port_path):
-            tmp = config.return_value(port_path)
-            listen_port.append(tmp)
-
-        listen_address_path = base + ['virtual-host', virtual_host, 'listen-address']
-        if config.exists(listen_address_path):
-            tmp = config.return_value(listen_address_path)
-            listen_address.append(tmp)
-
-    config.delete(base + ['virtual-host'])
-    for client in allow_client:
-        config.set(base + ['allow-client', 'address'], value=client, replace=False)
-
-    #  clear listen-address if "all" were specified
-    if '*' in listen_address:
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    if config.exists(base + ['certificates', 'certbot']):
+        # both domain-name and email must be set on CLI - ensured by previous verify()
+        domain_names = config.return_values(base + ['certificates', 'certbot', 'domain-name'])
+        email = config.return_value(base + ['certificates', 'certbot', 'email'])
+        config.delete(base + ['certificates', 'certbot'])
+
+        # Set default certname based on domain-name
+        cert_name = 'https-' + domain_names[0].split('.')[0]
+        # Overwrite certname from previous certbot calls if available
+        # We can not use python code like os.scandir due to filesystem permissions.
+        # This must be run as root
+        certbot_live = f'{vyos_certbot_dir}/live/' # we need the trailing /
+        if os.path.exists(certbot_live):
+            tmp = cmd(f'sudo find {certbot_live} -maxdepth 1 -type d')
+            tmp = tmp.split() # tmp = ['/config/auth/letsencrypt/live', '/config/auth/letsencrypt/live/router.vyos.net']
+            tmp.remove(certbot_live)
+            cert_name = tmp[0].replace(certbot_live, '')
+
+        config.set(['pki', 'certificate', cert_name, 'acme', 'email'], value=email)
+        config.set_tag(['pki', 'certificate'])
+        for domain in domain_names:
+            config.set(['pki', 'certificate', cert_name, 'acme', 'domain-name'], value=domain, replace=False)
+
+        # Update Webserver certificate
+        config.set(base + ['certificates', 'certificate'], value=cert_name)
+
+    if config.exists(base + ['virtual-host']):
+        allow_client = []
+        listen_port = []
         listen_address = []
-    for address in listen_address:
-        config.set(base + ['listen-address'], value=address, replace=False)
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+        for virtual_host in config.list_nodes(base + ['virtual-host']):
+            allow_path = base + ['virtual-host', virtual_host, 'allow-client', 'address']
+            if config.exists(allow_path):
+                tmp = config.return_values(allow_path)
+                allow_client.extend(tmp)
+
+            port_path = base + ['virtual-host', virtual_host, 'port']
+            if config.exists(port_path):
+                tmp = config.return_value(port_path)
+                listen_port.append(tmp)
+
+            listen_address_path = base + ['virtual-host', virtual_host, 'listen-address']
+            if config.exists(listen_address_path):
+                tmp = config.return_value(listen_address_path)
+                listen_address.append(tmp)
+
+        config.delete(base + ['virtual-host'])
+        for client in allow_client:
+            config.set(base + ['allow-client', 'address'], value=client, replace=False)
+
+        #  clear listen-address if "all" were specified
+        if '*' in listen_address:
+            listen_address = []
+        for address in listen_address:
+            config.set(base + ['listen-address'], value=address, replace=False)
diff --git a/src/migration-scripts/ids/0-to-1 b/src/migration-scripts/ids/0-to-1
old mode 100755
new mode 100644
index 8b7850a1a..1b963e839
--- a/src/migration-scripts/ids/0-to-1
+++ b/src/migration-scripts/ids/0-to-1
@@ -1,56 +1,38 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-from sys import argv
-from sys import exit
+# T4557: Migrate threshold and add new threshold types
 
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['service', 'ids', 'ddos-protection']
-config = ConfigTree(config_file)
-
-if not config.exists(base + ['threshold']):
-    # Nothing to do
-    exit(0)
-else:
-    if config.exists(base + ['threshold', 'fps']):
-        tmp = config.return_value(base + ['threshold', 'fps'])
-        config.delete(base + ['threshold', 'fps'])
-        config.set(base + ['threshold', 'general', 'fps'], value=tmp)
-    if config.exists(base + ['threshold', 'mbps']):
-        tmp = config.return_value(base + ['threshold', 'mbps'])
-        config.delete(base + ['threshold', 'mbps'])
-        config.set(base + ['threshold', 'general', 'mbps'], value=tmp)
-    if config.exists(base + ['threshold', 'pps']):
-        tmp = config.return_value(base + ['threshold', 'pps'])
-        config.delete(base + ['threshold', 'pps'])
-        config.set(base + ['threshold', 'general', 'pps'], value=tmp)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base + ['threshold']):
+        # Nothing to do
+        return
+    else:
+        if config.exists(base + ['threshold', 'fps']):
+            tmp = config.return_value(base + ['threshold', 'fps'])
+            config.delete(base + ['threshold', 'fps'])
+            config.set(base + ['threshold', 'general', 'fps'], value=tmp)
+        if config.exists(base + ['threshold', 'mbps']):
+            tmp = config.return_value(base + ['threshold', 'mbps'])
+            config.delete(base + ['threshold', 'mbps'])
+            config.set(base + ['threshold', 'general', 'mbps'], value=tmp)
+        if config.exists(base + ['threshold', 'pps']):
+            tmp = config.return_value(base + ['threshold', 'pps'])
+            config.delete(base + ['threshold', 'pps'])
+            config.set(base + ['threshold', 'general', 'pps'], value=tmp)
diff --git a/src/migration-scripts/interfaces/0-to-1 b/src/migration-scripts/interfaces/0-to-1
old mode 100755
new mode 100644
index 25f6842eb..7c135e76e
--- a/src/migration-scripts/interfaces/0-to-1
+++ b/src/migration-scripts/interfaces/0-to-1
@@ -1,118 +1,113 @@
-#!/usr/bin/env python3
+# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Change syntax of bridge interface
 # - move interface based bridge-group to actual bridge (de-nest)
 # - make stp and igmp-snooping nodes valueless
 # https://vyos.dev/T1556
 
-import sys
 from vyos.configtree import ConfigTree
 
 def migrate_bridge(config, tree, intf):
     # check if bridge-group exists
     tree_bridge = tree + ['bridge-group']
     if config.exists(tree_bridge):
         bridge = config.return_value(tree_bridge + ['bridge'])
         # create new bridge member interface
         config.set(base + [bridge, 'member', 'interface', intf])
         # format as tag node to avoid loading problems
         config.set_tag(base + [bridge, 'member', 'interface'])
 
         # cost: migrate if configured
         tree_cost = tree + ['bridge-group', 'cost']
         if config.exists(tree_cost):
             cost = config.return_value(tree_cost)
             # set new node
             config.set(base + [bridge, 'member', 'interface', intf, 'cost'], value=cost)
 
         # priority: migrate if configured
         tree_priority = tree + ['bridge-group', 'priority']
         if config.exists(tree_priority):
             priority = config.return_value(tree_priority)
             # set new node
             config.set(base + [bridge, 'member', 'interface', intf, 'priority'], value=priority)
 
         # Delete the old bridge-group assigned to an interface
         config.delete(tree_bridge)
 
 
-if __name__ == '__main__':
-    if len(sys.argv) < 2:
-        print("Must specify file name!")
-        sys.exit(1)
-
-    file_name = sys.argv[1]
-
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
+def migrate(config: ConfigTree) -> None:
     base = ['interfaces', 'bridge']
 
     if not config.exists(base):
         # Nothing to do
-        sys.exit(0)
-    else:
-        #
-        # make stp and igmp-snooping nodes valueless
-        #
-        for br in config.list_nodes(base):
-            # STP: check if enabled
-            if config.exists(base + [br, 'stp']):
-                stp_val = config.return_value(base + [br, 'stp'])
-                # STP: delete node with old syntax
-                config.delete(base + [br, 'stp'])
-                # STP: set new node - if enabled
-                if stp_val == "true":
-                    config.set(base + [br, 'stp'], value=None)
-
-            # igmp-snooping: check if enabled
-            if config.exists(base + [br, 'igmp-snooping', 'querier']):
-                igmp_val = config.return_value(base + [br, 'igmp-snooping', 'querier'])
-                # igmp-snooping: delete node with old syntax
-                config.delete(base + [br, 'igmp-snooping', 'querier'])
-                # igmp-snooping: set new node - if enabled
-                if igmp_val == "enable":
-                    config.set(base + [br, 'igmp', 'querier'], value=None)
-
-        #
-        # move interface based bridge-group to actual bridge (de-nest)
-        #
-        bridge_types = ['bonding', 'ethernet', 'l2tpv3', 'openvpn', 'vxlan', 'wireless']
-        for type in bridge_types:
-            if not config.exists(['interfaces', type]):
-                continue
-
-            for interface in config.list_nodes(['interfaces', type]):
-                # check if bridge-group exists
-                bridge_group = ['interfaces', type, interface]
-                if config.exists(bridge_group + ['bridge-group']):
-                    migrate_bridge(config, bridge_group, interface)
-
-                # We also need to migrate VLAN interfaces
-                vlan_base = ['interfaces', type, interface, 'vif']
-                if config.exists(vlan_base):
-                    for vlan in config.list_nodes(vlan_base):
-                        intf = "{}.{}".format(interface, vlan)
-                        migrate_bridge(config, vlan_base + [vlan], intf)
-
-                # And then we have service VLANs (vif-s) interfaces
-                vlan_base = ['interfaces', type, interface, 'vif-s']
-                if config.exists(vlan_base):
-                    for vif_s in config.list_nodes(vlan_base):
-                        intf = "{}.{}".format(interface, vif_s)
-                        migrate_bridge(config, vlan_base + [vif_s], intf)
-
-                        # Every service VLAN can have multiple customer VLANs (vif-c)
-                        vlan_c = ['interfaces', type, interface, 'vif-s', vif_s, 'vif-c']
-                        if config.exists(vlan_c):
-                            for vif_c in config.list_nodes(vlan_c):
-                                intf = "{}.{}.{}".format(interface, vif_s, vif_c)
-                                migrate_bridge(config, vlan_c + [vif_c], intf)
-
-        try:
-            with open(file_name, 'w') as f:
-                f.write(config.to_string())
-        except OSError as e:
-            print("Failed to save the modified config: {}".format(e))
-            sys.exit(1)
+        return
+
+    #
+    # make stp and igmp-snooping nodes valueless
+    #
+    for br in config.list_nodes(base):
+        # STP: check if enabled
+        if config.exists(base + [br, 'stp']):
+            stp_val = config.return_value(base + [br, 'stp'])
+            # STP: delete node with old syntax
+            config.delete(base + [br, 'stp'])
+            # STP: set new node - if enabled
+            if stp_val == "true":
+                config.set(base + [br, 'stp'], value=None)
+
+        # igmp-snooping: check if enabled
+        if config.exists(base + [br, 'igmp-snooping', 'querier']):
+            igmp_val = config.return_value(base + [br, 'igmp-snooping', 'querier'])
+            # igmp-snooping: delete node with old syntax
+            config.delete(base + [br, 'igmp-snooping', 'querier'])
+            # igmp-snooping: set new node - if enabled
+            if igmp_val == "enable":
+                config.set(base + [br, 'igmp', 'querier'], value=None)
+
+    #
+    # move interface based bridge-group to actual bridge (de-nest)
+    #
+    bridge_types = ['bonding', 'ethernet', 'l2tpv3', 'openvpn', 'vxlan', 'wireless']
+    for type in bridge_types:
+        if not config.exists(['interfaces', type]):
+            continue
+
+        for interface in config.list_nodes(['interfaces', type]):
+            # check if bridge-group exists
+            bridge_group = ['interfaces', type, interface]
+            if config.exists(bridge_group + ['bridge-group']):
+                migrate_bridge(config, bridge_group, interface)
+
+            # We also need to migrate VLAN interfaces
+            vlan_base = ['interfaces', type, interface, 'vif']
+            if config.exists(vlan_base):
+                for vlan in config.list_nodes(vlan_base):
+                    intf = "{}.{}".format(interface, vlan)
+                    migrate_bridge(config, vlan_base + [vlan], intf)
+
+            # And then we have service VLANs (vif-s) interfaces
+            vlan_base = ['interfaces', type, interface, 'vif-s']
+            if config.exists(vlan_base):
+                for vif_s in config.list_nodes(vlan_base):
+                    intf = "{}.{}".format(interface, vif_s)
+                    migrate_bridge(config, vlan_base + [vif_s], intf)
+
+                    # Every service VLAN can have multiple customer VLANs (vif-c)
+                    vlan_c = ['interfaces', type, interface, 'vif-s', vif_s, 'vif-c']
+                    if config.exists(vlan_c):
+                        for vif_c in config.list_nodes(vlan_c):
+                            intf = "{}.{}.{}".format(interface, vif_s, vif_c)
+                            migrate_bridge(config, vlan_c + [vif_c], intf)
diff --git a/src/migration-scripts/interfaces/1-to-2 b/src/migration-scripts/interfaces/1-to-2
old mode 100755
new mode 100644
index c95623c2b..ebf02b028
--- a/src/migration-scripts/interfaces/1-to-2
+++ b/src/migration-scripts/interfaces/1-to-2
@@ -1,63 +1,59 @@
-#!/usr/bin/env python3
+# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Change syntax of bond interface
 # - move interface based bond-group to actual bond (de-nest)
 # https://vyos.dev/T1614
 
-import sys
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['interfaces', 'bonding']
 
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
-else:
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
     #
     # move interface based bond-group to actual bond (de-nest)
     #
     for intf in config.list_nodes(['interfaces', 'ethernet']):
         # check if bond-group exists
         if config.exists(['interfaces', 'ethernet', intf, 'bond-group']):
             # get configured bond interface
             bond = config.return_value(['interfaces', 'ethernet', intf, 'bond-group'])
             # delete old interface asigned (nested) bond group
             config.delete(['interfaces', 'ethernet', intf, 'bond-group'])
             # create new bond member interface
             config.set(base + [bond, 'member', 'interface'], value=intf, replace=False)
 
     #
     # some combinations were allowed in the past from a CLI perspective
     # but the kernel overwrote them - remove from CLI to not confuse the users.
     # In addition new consitency checks are in place so users can't repeat the
     # mistake. One of those nice issues is https://vyos.dev/T532
     for bond in config.list_nodes(base):
         if config.exists(base + [bond, 'arp-monitor', 'interval']) and config.exists(base + [bond, 'mode']):
             mode = config.return_value(base + [bond, 'mode'])
             if mode in ['802.3ad', 'transmit-load-balance', 'adaptive-load-balance']:
                 intvl = int(config.return_value(base + [bond, 'arp-monitor', 'interval']))
                 if intvl > 0:
                     # this is not allowed and the linux kernel replies with:
                     # option arp_interval: mode dependency failed, not supported in mode 802.3ad(4)
                     # option arp_interval: mode dependency failed, not supported in mode balance-alb(6)
                     # option arp_interval: mode dependency failed, not supported in mode balance-tlb(5)
                     #
                     # so we simply disable arp_interval by setting it to 0 and miimon will take care about the link
                     config.set(base + [bond, 'arp-monitor', 'interval'], value='0')
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/interfaces/10-to-11 b/src/migration-scripts/interfaces/10-to-11
old mode 100755
new mode 100644
index cafaa3fa4..8a562f2d0
--- a/src/migration-scripts/interfaces/10-to-11
+++ b/src/migration-scripts/interfaces/10-to-11
@@ -1,55 +1,38 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # rename WWAN (wirelessmodem) serial interface from non persistent ttyUSB2 to
 # a bus like name, e.g. "usb0b1.3p1.3"
 
 import os
 
-from sys import exit, argv
 from vyos.configtree import ConfigTree
 
-if __name__ == '__main__':
-    if len(argv) < 2:
-        print("Must specify file name!")
-        exit(1)
-
-    file_name = argv[1]
-    with open(file_name, 'r') as f:
-        config_file = f.read()
+base = ['interfaces', 'wirelessmodem']
 
-    config = ConfigTree(config_file)
-    base = ['interfaces', 'wirelessmodem']
+def migrate(config: ConfigTree) -> None:
     if not config.exists(base):
         # Nothing to do
-        exit(0)
+        return
 
     for wwan in config.list_nodes(base):
         if config.exists(base + [wwan, 'device']):
             device = config.return_value(base + [wwan, 'device'])
 
             for root, dirs, files in os.walk('/dev/serial/by-bus'):
                 for file in files:
                     device_file = os.path.realpath(os.path.join(root, file))
                     if os.path.basename(device_file) == device:
                         config.set(base + [wwan, 'device'], value=file, replace=True)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/interfaces/11-to-12 b/src/migration-scripts/interfaces/11-to-12
old mode 100755
new mode 100644
index e9eb7f939..132cecbb7
--- a/src/migration-scripts/interfaces/11-to-12
+++ b/src/migration-scripts/interfaces/11-to-12
@@ -1,58 +1,39 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - rename 'dhcpv6-options prefix-delegation' from single node to a new tag node
 #   'dhcpv6-options pd 0'
 # - delete 'sla-len' from CLI - value is calculated on demand
 
-from sys import exit, argv
 from vyos.configtree import ConfigTree
 
-if __name__ == '__main__':
-    if len(argv) < 2:
-        print("Must specify file name!")
-        exit(1)
-
-    file_name = argv[1]
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
-
+def migrate(config: ConfigTree) -> None:
     for type in config.list_nodes(['interfaces']):
         for interface in config.list_nodes(['interfaces', type]):
             # cache current config tree
             base_path = ['interfaces', type, interface, 'dhcpv6-options']
             old_base = base_path + ['prefix-delegation']
             new_base = base_path + ['pd']
             if config.exists(old_base):
                 config.set(new_base)
                 config.set_tag(new_base)
                 config.copy(old_base, new_base + ['0'])
                 config.delete(old_base)
 
                 for pd in config.list_nodes(new_base):
                     for tmp in config.list_nodes(new_base + [pd, 'interface']):
                         sla_config = new_base + [pd, 'interface', tmp, 'sla-len']
                         if config.exists(sla_config):
                             config.delete(sla_config)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/interfaces/12-to-13 b/src/migration-scripts/interfaces/12-to-13
old mode 100755
new mode 100644
index ef1d93903..585deb898
--- a/src/migration-scripts/interfaces/12-to-13
+++ b/src/migration-scripts/interfaces/12-to-13
@@ -1,71 +1,51 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - T2903: Change vif-s ethertype from numeric number to literal
 #   - 0x88a8 -> 802.1ad
 #   - 0x8100 -> 802.1q
 # - T2905: Change WWAN "ondemand" node to "connect-on-demand" to have identical
 #   CLI nodes for both types of dialer interfaces
 
-from sys import exit, argv
 from vyos.configtree import ConfigTree
 
-if __name__ == '__main__':
-    if len(argv) < 2:
-        print("Must specify file name!")
-        exit(1)
-
-    file_name = argv[1]
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
-
+def migrate(config: ConfigTree) -> None:
     #
     # T2903
     #
     for type in config.list_nodes(['interfaces']):
         for interface in config.list_nodes(['interfaces', type]):
             if not config.exists(['interfaces', type, interface, 'vif-s']):
                 continue
 
             for vif_s in config.list_nodes(['interfaces', type, interface, 'vif-s']):
                 base_path = ['interfaces', type, interface, 'vif-s', vif_s]
                 if config.exists(base_path + ['ethertype']):
                     protocol = '802.1ad'
                     tmp = config.return_value(base_path + ['ethertype'])
                     if tmp == '0x8100':
                         protocol = '802.1q'
 
                     config.set(base_path + ['protocol'], value=protocol)
                     config.delete(base_path + ['ethertype'])
 
     #
     # T2905
     #
     wwan_base = ['interfaces', 'wirelessmodem']
     if config.exists(wwan_base):
         for interface in config.list_nodes(wwan_base):
             if config.exists(wwan_base + [interface, 'ondemand']):
                 config.rename(wwan_base + [interface, 'ondemand'], 'connect-on-demand')
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
-
diff --git a/src/migration-scripts/interfaces/13-to-14 b/src/migration-scripts/interfaces/13-to-14
old mode 100755
new mode 100644
index b20d8b4db..45d8e3b5f
--- a/src/migration-scripts/interfaces/13-to-14
+++ b/src/migration-scripts/interfaces/13-to-14
@@ -1,59 +1,42 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3043: rename Wireless interface security mode 'both' to 'wpa+wpa2'
 # T3043: move "system wifi-regulatory-domain" to indicidual wireless interface
 
-from sys import exit, argv
 from vyos.configtree import ConfigTree
 
-if __name__ == '__main__':
-    if len(argv) < 2:
-        print("Must specify file name!")
-        exit(1)
-
-    file_name = argv[1]
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
+def migrate(config: ConfigTree) -> None:
     base = ['interfaces', 'wireless']
+
     if not config.exists(base):
         # Nothing to do
-        exit(0)
+        return
 
     country_code = ''
     cc_cli = ['system', 'wifi-regulatory-domain']
     if config.exists(cc_cli):
         country_code = config.return_value(cc_cli)
         config.delete(cc_cli)
 
     for wifi in config.list_nodes(base):
         sec_mode = base + [wifi, 'security', 'wpa', 'mode']
         if config.exists(sec_mode):
             mode = config.return_value(sec_mode)
             if mode == 'both':
                 config.set(sec_mode, value='wpa+wpa2', replace=True)
 
         if country_code:
             config.set(base + [wifi, 'country-code'], value=country_code)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/interfaces/14-to-15 b/src/migration-scripts/interfaces/14-to-15
old mode 100755
new mode 100644
index e21251f86..d45d59bba
--- a/src/migration-scripts/interfaces/14-to-15
+++ b/src/migration-scripts/interfaces/14-to-15
@@ -1,56 +1,38 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3048: remove smp-affinity node from ethernet and use tuned instead
 
-from sys import exit, argv
 from vyos.configtree import ConfigTree
 
-if __name__ == '__main__':
-    if len(argv) < 2:
-        print("Must specify file name!")
-        exit(1)
-
-    file_name = argv[1]
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
+def migrate(config: ConfigTree) -> None:
     base = ['interfaces', 'ethernet']
 
     if not config.exists(base):
         # Nothing to do
-        exit(0)
+        return
 
     migrate = False
     for interface in config.list_nodes(base):
         smp_base = base + [interface, 'smp-affinity']
         # if any one interface had smp-affinity configured manually, we will
         # configure "system option performance"
         if config.exists(smp_base):
             if config.return_value(smp_base) != 'auto':
                 migrate = True
             config.delete(smp_base)
 
     if migrate:
         config.set(['system', 'options', 'performance'], value='throughput')
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/interfaces/15-to-16 b/src/migration-scripts/interfaces/15-to-16
old mode 100755
new mode 100644
index ae3441b9f..c9abdb5f8
--- a/src/migration-scripts/interfaces/15-to-16
+++ b/src/migration-scripts/interfaces/15-to-16
@@ -1,48 +1,30 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # remove pppoe "ipv6 enable" option
 
-from sys import exit, argv
 from vyos.configtree import ConfigTree
 
-if __name__ == '__main__':
-    if len(argv) < 2:
-        print("Must specify file name!")
-        exit(1)
-
-    file_name = argv[1]
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
+def migrate(config: ConfigTree) -> None:
     base = ['interfaces', 'pppoe']
 
     if not config.exists(base):
         # Nothing to do
-        exit(0)
+        return
 
     for interface in config.list_nodes(base):
         ipv6_enable = base + [interface, 'ipv6', 'enable']
         if config.exists(ipv6_enable):
             config.delete(ipv6_enable)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/interfaces/16-to-17 b/src/migration-scripts/interfaces/16-to-17
old mode 100755
new mode 100644
index 75f160686..7d241ac68
--- a/src/migration-scripts/interfaces/16-to-17
+++ b/src/migration-scripts/interfaces/16-to-17
@@ -1,52 +1,33 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Command line migration of port mirroring
 # https://vyos.dev/T3089
 
-import sys
 from vyos.configtree import ConfigTree
 
-if __name__ == '__main__':
-    if len(sys.argv) < 2:
-        print("Must specify file name!")
-        sys.exit(1)
-
-    file_name = sys.argv[1]
-
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
+def migrate(config: ConfigTree) -> None:
     base = ['interfaces', 'ethernet']
     if not config.exists(base):
         # Nothing to do
-        sys.exit(0)
+        return
 
     for interface in config.list_nodes(base):
         mirror_old_base = base + [interface, 'mirror']
         if config.exists(mirror_old_base):
             intf = config.return_values(mirror_old_base)
             if config.exists(mirror_old_base):
                 config.delete(mirror_old_base)
                 config.set(mirror_old_base + ['ingress'],intf[0])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/interfaces/17-to-18 b/src/migration-scripts/interfaces/17-to-18
old mode 100755
new mode 100644
index 51486ac37..f45695a88
--- a/src/migration-scripts/interfaces/17-to-18
+++ b/src/migration-scripts/interfaces/17-to-18
@@ -1,71 +1,52 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3043: Move "system wifi-regulatory-domain" to indicidual wireless interface.
 #        Country Code will be migratred from upper to lower case.
 # T3140: Relax ethernet interface offload-options
 
-from sys import exit, argv
 from vyos.configtree import ConfigTree
 
-if __name__ == '__main__':
-    if len(argv) < 2:
-        print("Must specify file name!")
-        exit(1)
-
-    file_name = argv[1]
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
-
+def migrate(config: ConfigTree) -> None:
     # T3140: Cleanup ethernet offload-options, remove on/off value and use
     # valueless nodes instead.
     eth_base = ['interfaces', 'ethernet']
     if config.exists(eth_base):
         for eth in config.list_nodes(eth_base):
            offload = eth_base + [eth, 'offload-options']
            if config.exists(offload):
                 mapping = {
                     'generic-receive'      : 'gro',
                     'generic-segmentation' : 'gso',
                     'scatter-gather'       : 'sg',
                     'tcp-segmentation'     : 'tso',
                     'udp-fragmentation'    : 'ufo',
                 }
                 for k, v in mapping.items():
                     if config.exists(offload + [k]):
                         tmp = config.return_value(offload + [k])
                         if tmp == 'on':
                             config.set(eth_base + [eth, 'offload', v])
 
                 config.delete(offload)
 
     # T3043: WIFI country-code should be lower-case
     wifi_base = ['interfaces', 'wireless']
     if config.exists(wifi_base):
         for wifi in config.list_nodes(wifi_base):
             ccode = wifi_base + [wifi, 'country-code']
             if config.exists(ccode):
                 tmp = config.return_value(ccode)
                 config.set(ccode, value=tmp.lower(), replace=True)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/interfaces/18-to-19 b/src/migration-scripts/interfaces/18-to-19
old mode 100755
new mode 100644
index c3209f250..ae1a07adb
--- a/src/migration-scripts/interfaces/18-to-19
+++ b/src/migration-scripts/interfaces/18-to-19
@@ -1,107 +1,86 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 import os
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
 def replace_nat_interfaces(config, old, new):
     if not config.exists(['nat']):
         return
     for direction in ['destination', 'source']:
         conf_direction = ['nat', direction, 'rule']
         if not config.exists(conf_direction):
             return
         for rule in config.list_nodes(conf_direction):
             conf_rule = conf_direction + [rule]
             if config.exists(conf_rule + ['inbound-interface']):
                 tmp = config.return_value(conf_rule + ['inbound-interface'])
                 if tmp == old:
                     config.set(conf_rule + ['inbound-interface'], value=new)
             if config.exists(conf_rule + ['outbound-interface']):
                 tmp = config.return_value(conf_rule + ['outbound-interface'])
                 if tmp == old:
                     config.set(conf_rule + ['outbound-interface'], value=new)
 
 
-if __name__ == '__main__':
-    if len(argv) < 2:
-        print("Must specify file name!")
-        exit(1)
-
-    file_name = argv[1]
-
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
+def migrate(config: ConfigTree) -> None:
     base = ['interfaces', 'wirelessmodem']
     if not config.exists(base):
         # Nothing to do
-        exit(0)
+        return
 
     new_base = ['interfaces', 'wwan']
     config.set(new_base)
     config.set_tag(new_base)
     for old_interface in config.list_nodes(base):
         # convert usb0b1.3p1.2 device identifier and extract 1.3 usb bus id
         usb = config.return_value(base + [old_interface, 'device'])
         device = usb.split('b')[-1]
         busid = device.split('p')[0]
         for new_interface in os.listdir('/sys/class/net'):
             # we are only interested in interfaces starting with wwan
             if not new_interface.startswith('wwan'):
                 continue
             device = os.readlink(f'/sys/class/net/{new_interface}/device')
             device = device.split(':')[0]
             if busid in device:
                 config.copy(base + [old_interface], new_base + [new_interface])
                 replace_nat_interfaces(config, old_interface, new_interface)
 
     config.delete(base)
 
     # Now that we have copied the old wirelessmodem interfaces to wwan
     # we can start to migrate also individual config items.
     for interface in config.list_nodes(new_base):
         # we do no longer need the USB device name
         config.delete(new_base + [interface, 'device'])
         # set/unset DNS configuration
         dns = new_base + [interface, 'no-peer-dns']
         if config.exists(dns):
             config.delete(dns)
         else:
             config.set(['system', 'name-servers-dhcp'], value=interface, replace=False)
 
         # Backup distance is now handled by DHCP option "default-route-distance"
         distance = dns = new_base + [interface, 'backup', 'distance']
         old_default_distance = '10'
         if config.exists(distance):
             old_default_distance = config.return_value(distance)
             config.delete(distance)
         config.set(new_base + [interface, 'dhcp-options', 'default-route-distance'], value=old_default_distance)
 
         # the new wwan interface use regular IP addressing
         config.set(new_base + [interface, 'address'], value='dhcp')
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/interfaces/19-to-20 b/src/migration-scripts/interfaces/19-to-20
old mode 100755
new mode 100644
index 05abae898..7ee6302e2
--- a/src/migration-scripts/interfaces/19-to-20
+++ b/src/migration-scripts/interfaces/19-to-20
@@ -1,61 +1,41 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if __name__ == '__main__':
-    if len(argv) < 2:
-        print("Must specify file name!")
-        exit(1)
-
-    file_name = argv[1]
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
-
+def migrate(config: ConfigTree) -> None:
     for type in ['tunnel', 'l2tpv3']:
         base = ['interfaces', type]
         if not config.exists(base):
             # Nothing to do
             continue
 
         for interface in config.list_nodes(base):
             # Migrate "interface tunnel <tunX> encapsulation gre-bridge" to gretap
             encap_path = base + [interface, 'encapsulation']
             if type == 'tunnel' and config.exists(encap_path):
                 tmp = config.return_value(encap_path)
                 if tmp == 'gre-bridge':
                     config.set(encap_path, value='gretap')
 
             # Migrate "interface tunnel|l2tpv3 <interface> local-ip" to source-address
             # Migrate "interface tunnel|l2tpv3 <interface> remote-ip" to remote
             local_ip_path = base + [interface, 'local-ip']
             if config.exists(local_ip_path):
                 config.rename(local_ip_path, 'source-address')
 
             remote_ip_path = base + [interface, 'remote-ip']
             if config.exists(remote_ip_path):
                 config.rename(remote_ip_path, 'remote')
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/interfaces/2-to-3 b/src/migration-scripts/interfaces/2-to-3
old mode 100755
new mode 100644
index 15c3bc8be..695dcbf7a
--- a/src/migration-scripts/interfaces/2-to-3
+++ b/src/migration-scripts/interfaces/2-to-3
@@ -1,43 +1,40 @@
-#!/usr/bin/env python3
+# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Change syntax of openvpn encryption settings
 # - move cipher from encryption to encryption cipher
 # https://vyos.dev/T1704
 
-import sys
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['interfaces', 'openvpn']
 
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
-else:
+def migrate(config: ConfigTree) -> None:
+
+    if not config.exists(base):
+        # Nothing to do
+        return
     #
     # move cipher from "encryption" to "encryption cipher"
     #
     for intf in config.list_nodes(['interfaces', 'openvpn']):
         # Check if encryption is set
         if config.exists(['interfaces', 'openvpn', intf, 'encryption']):
             # Get cipher used
             cipher = config.return_value(['interfaces', 'openvpn', intf, 'encryption'])
             # Delete old syntax
             config.delete(['interfaces', 'openvpn', intf, 'encryption'])
             # Add new syntax to config
             config.set(['interfaces', 'openvpn', intf, 'encryption', 'cipher'], value=cipher)
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/interfaces/20-to-21 b/src/migration-scripts/interfaces/20-to-21
old mode 100755
new mode 100644
index 05a0c7237..0b6895177
--- a/src/migration-scripts/interfaces/20-to-21
+++ b/src/migration-scripts/interfaces/20-to-21
@@ -1,125 +1,107 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3619: mirror Linux Kernel defaults for ethernet offloading options into VyOS
 #        CLI. See https://vyos.dev/T3619#102254 for all the details.
 # T3787: Remove deprecated UDP fragmentation offloading option
 
-from sys import argv
-
 from vyos.ethtool import Ethtool
 from vyos.configtree import ConfigTree
 from vyos.utils.network import interface_exists
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['interfaces', 'ethernet']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    exit(0)
-
-for ifname in config.list_nodes(base):
-    # Bail out early if interface vanished from system
-    if not interface_exists(ifname):
-        continue
-
-    eth = Ethtool(ifname)
-
-    # If GRO is enabled by the Kernel - we reflect this on the CLI. If GRO is
-    # enabled via CLI but not supported by the NIC - we remove it from the CLI
-    configured = config.exists(base + [ifname, 'offload', 'gro'])
-    enabled, fixed = eth.get_generic_receive_offload()
-    if configured and fixed:
-        config.delete(base + [ifname, 'offload', 'gro'])
-    elif enabled and not fixed:
-        config.set(base + [ifname, 'offload', 'gro'])
-
-    # If GSO is enabled by the Kernel - we reflect this on the CLI. If GSO is
-    # enabled via CLI but not supported by the NIC - we remove it from the CLI
-    configured = config.exists(base + [ifname, 'offload', 'gso'])
-    enabled, fixed = eth.get_generic_segmentation_offload()
-    if configured and fixed:
-        config.delete(base + [ifname, 'offload', 'gso'])
-    elif enabled and not fixed:
-        config.set(base + [ifname, 'offload', 'gso'])
-
-    # If LRO is enabled by the Kernel - we reflect this on the CLI. If LRO is
-    # enabled via CLI but not supported by the NIC - we remove it from the CLI
-    configured = config.exists(base + [ifname, 'offload', 'lro'])
-    enabled, fixed = eth.get_large_receive_offload()
-    if configured and fixed:
-        config.delete(base + [ifname, 'offload', 'lro'])
-    elif enabled and not fixed:
-        config.set(base + [ifname, 'offload', 'lro'])
-
-    # If SG is enabled by the Kernel - we reflect this on the CLI. If SG is
-    # enabled via CLI but not supported by the NIC - we remove it from the CLI
-    configured = config.exists(base + [ifname, 'offload', 'sg'])
-    enabled, fixed = eth.get_scatter_gather()
-    if configured and fixed:
-        config.delete(base + [ifname, 'offload', 'sg'])
-    elif enabled and not fixed:
-        config.set(base + [ifname, 'offload', 'sg'])
-
-    # If TSO is enabled by the Kernel - we reflect this on the CLI. If TSO is
-    # enabled via CLI but not supported by the NIC - we remove it from the CLI
-    configured = config.exists(base + [ifname, 'offload', 'tso'])
-    enabled, fixed = eth.get_tcp_segmentation_offload()
-    if configured and fixed:
-        config.delete(base + [ifname, 'offload', 'tso'])
-    elif enabled and not fixed:
-        config.set(base + [ifname, 'offload', 'tso'])
-
-    # Remove deprecated UDP fragmentation offloading option
-    if config.exists(base + [ifname, 'offload', 'ufo']):
-        config.delete(base + [ifname, 'offload', 'ufo'])
-
-    # Also while processing the interface configuration, not all adapters support
-    # changing the speed and duplex settings. If the desired speed and duplex
-    # values do not work for the NIC driver, we change them back to the default
-    # value of "auto" - which will be applied if the CLI node is deleted.
-    speed_path = base + [ifname, 'speed']
-    duplex_path = base + [ifname, 'duplex']
-    # speed and duplex must always be set at the same time if not set to "auto"
-    if config.exists(speed_path) and config.exists(duplex_path):
-        speed = config.return_value(speed_path)
-        duplex = config.return_value(duplex_path)
-        if speed != 'auto' and duplex != 'auto':
-            if not eth.check_speed_duplex(speed, duplex):
-                config.delete(speed_path)
-                config.delete(duplex_path)
-
-    # Also while processing the interface configuration, not all adapters support
-    # changing disabling flow-control - or change this setting. If disabling
-    # flow-control is not supported by the NIC, we remove the setting from CLI
-    flow_control_path = base + [ifname, 'disable-flow-control']
-    if config.exists(flow_control_path):
-        if not eth.check_flow_control():
-            config.delete(flow_control_path)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
+
+    for ifname in config.list_nodes(base):
+        # Bail out early if interface vanished from system
+        if not interface_exists(ifname):
+            continue
+
+        eth = Ethtool(ifname)
+
+        # If GRO is enabled by the Kernel - we reflect this on the CLI. If GRO is
+        # enabled via CLI but not supported by the NIC - we remove it from the CLI
+        configured = config.exists(base + [ifname, 'offload', 'gro'])
+        enabled, fixed = eth.get_generic_receive_offload()
+        if configured and fixed:
+            config.delete(base + [ifname, 'offload', 'gro'])
+        elif enabled and not fixed:
+            config.set(base + [ifname, 'offload', 'gro'])
+
+        # If GSO is enabled by the Kernel - we reflect this on the CLI. If GSO is
+        # enabled via CLI but not supported by the NIC - we remove it from the CLI
+        configured = config.exists(base + [ifname, 'offload', 'gso'])
+        enabled, fixed = eth.get_generic_segmentation_offload()
+        if configured and fixed:
+            config.delete(base + [ifname, 'offload', 'gso'])
+        elif enabled and not fixed:
+            config.set(base + [ifname, 'offload', 'gso'])
+
+        # If LRO is enabled by the Kernel - we reflect this on the CLI. If LRO is
+        # enabled via CLI but not supported by the NIC - we remove it from the CLI
+        configured = config.exists(base + [ifname, 'offload', 'lro'])
+        enabled, fixed = eth.get_large_receive_offload()
+        if configured and fixed:
+            config.delete(base + [ifname, 'offload', 'lro'])
+        elif enabled and not fixed:
+            config.set(base + [ifname, 'offload', 'lro'])
+
+        # If SG is enabled by the Kernel - we reflect this on the CLI. If SG is
+        # enabled via CLI but not supported by the NIC - we remove it from the CLI
+        configured = config.exists(base + [ifname, 'offload', 'sg'])
+        enabled, fixed = eth.get_scatter_gather()
+        if configured and fixed:
+            config.delete(base + [ifname, 'offload', 'sg'])
+        elif enabled and not fixed:
+            config.set(base + [ifname, 'offload', 'sg'])
+
+        # If TSO is enabled by the Kernel - we reflect this on the CLI. If TSO is
+        # enabled via CLI but not supported by the NIC - we remove it from the CLI
+        configured = config.exists(base + [ifname, 'offload', 'tso'])
+        enabled, fixed = eth.get_tcp_segmentation_offload()
+        if configured and fixed:
+            config.delete(base + [ifname, 'offload', 'tso'])
+        elif enabled and not fixed:
+            config.set(base + [ifname, 'offload', 'tso'])
+
+        # Remove deprecated UDP fragmentation offloading option
+        if config.exists(base + [ifname, 'offload', 'ufo']):
+            config.delete(base + [ifname, 'offload', 'ufo'])
+
+        # Also while processing the interface configuration, not all adapters support
+        # changing the speed and duplex settings. If the desired speed and duplex
+        # values do not work for the NIC driver, we change them back to the default
+        # value of "auto" - which will be applied if the CLI node is deleted.
+        speed_path = base + [ifname, 'speed']
+        duplex_path = base + [ifname, 'duplex']
+        # speed and duplex must always be set at the same time if not set to "auto"
+        if config.exists(speed_path) and config.exists(duplex_path):
+            speed = config.return_value(speed_path)
+            duplex = config.return_value(duplex_path)
+            if speed != 'auto' and duplex != 'auto':
+                if not eth.check_speed_duplex(speed, duplex):
+                    config.delete(speed_path)
+                    config.delete(duplex_path)
+
+        # Also while processing the interface configuration, not all adapters support
+        # changing disabling flow-control - or change this setting. If disabling
+        # flow-control is not supported by the NIC, we remove the setting from CLI
+        flow_control_path = base + [ifname, 'disable-flow-control']
+        if config.exists(flow_control_path):
+            if not eth.check_flow_control():
+                config.delete(flow_control_path)
diff --git a/src/migration-scripts/interfaces/21-to-22 b/src/migration-scripts/interfaces/21-to-22
old mode 100755
new mode 100644
index 1838eb1c0..046eb10c6
--- a/src/migration-scripts/interfaces/21-to-22
+++ b/src/migration-scripts/interfaces/21-to-22
@@ -1,46 +1,29 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-from sys import argv
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-base = ['interfaces', 'tunnel']
-
-if not config.exists(base):
-    exit(0)
+def migrate(config: ConfigTree) -> None:
+    base = ['interfaces', 'tunnel']
 
-for interface in config.list_nodes(base):
-    path = base + [interface, 'dhcp-interface']
-    if config.exists(path):
-        tmp = config.return_value(path)
-        config.delete(path)
-        config.set(base + [interface, 'source-interface'], value=tmp)
+    if not config.exists(base):
+        return
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    for interface in config.list_nodes(base):
+        path = base + [interface, 'dhcp-interface']
+        if config.exists(path):
+            tmp = config.return_value(path)
+            config.delete(path)
+            config.set(base + [interface, 'source-interface'], value=tmp)
diff --git a/src/migration-scripts/interfaces/22-to-23 b/src/migration-scripts/interfaces/22-to-23
old mode 100755
new mode 100644
index 04e023e77..31f7fa2ff
--- a/src/migration-scripts/interfaces/22-to-23
+++ b/src/migration-scripts/interfaces/22-to-23
@@ -1,57 +1,40 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021-2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
+
 # Deletes Wireguard peers if they have the same public key as the router has.
-import sys
+
 from vyos.configtree import ConfigTree
 from vyos.utils.network import is_wireguard_key_pair
 
-if __name__ == '__main__':
-    if len(sys.argv) < 2:
-        print("Must specify file name!")
-        sys.exit(1)
-
-    file_name = sys.argv[1]
-
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
+def migrate(config: ConfigTree) -> None:
     base = ['interfaces', 'wireguard']
     if not config.exists(base):
         # Nothing to do
-        sys.exit(0)
+        return
+
     for interface in config.list_nodes(base):
         if not config.exists(base + [interface, 'private-key']):
             continue
         private_key = config.return_value(base + [interface, 'private-key'])
         interface_base = base + [interface]
         if config.exists(interface_base + ['peer']):
             for peer in config.list_nodes(interface_base + ['peer']):
                 peer_base = interface_base + ['peer', peer]
                 if not config.exists(peer_base + ['public-key']):
                     continue
                 peer_public_key = config.return_value(peer_base + ['public-key'])
                 if not config.exists(peer_base + ['disable']) \
                         and is_wireguard_key_pair(private_key, peer_public_key):
                     config.set(peer_base + ['disable'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/interfaces/23-to-24 b/src/migration-scripts/interfaces/23-to-24
old mode 100755
new mode 100644
index 8b21fce51..b72ceee49
--- a/src/migration-scripts/interfaces/23-to-24
+++ b/src/migration-scripts/interfaces/23-to-24
@@ -1,145 +1,125 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
 def migrate_ospf(config, path, interface):
     path = path + ['ospf']
     if config.exists(path):
         new_base = ['protocols', 'ospf', 'interface']
         config.set(new_base)
         config.set_tag(new_base)
         config.copy(path, new_base + [interface])
         config.delete(path)
 
         # if "ip ospf" was the only setting, we can clean out the empty
         # ip node afterwards
         if len(config.list_nodes(path[:-1])) == 0:
             config.delete(path[:-1])
 
 def migrate_ospfv3(config, path, interface):
     path = path + ['ospfv3']
     if config.exists(path):
         new_base = ['protocols', 'ospfv3', 'interface']
         config.set(new_base)
         config.set_tag(new_base)
         config.copy(path, new_base + [interface])
         config.delete(path)
 
         # if "ipv6 ospfv3" was the only setting, we can clean out the empty
         # ip node afterwards
         if len(config.list_nodes(path[:-1])) == 0:
             config.delete(path[:-1])
 
 def migrate_rip(config, path, interface):
     path = path + ['rip']
     if config.exists(path):
         new_base = ['protocols', 'rip', 'interface']
         config.set(new_base)
         config.set_tag(new_base)
         config.copy(path, new_base + [interface])
         config.delete(path)
 
         # if "ip rip" was the only setting, we can clean out the empty
         # ip node afterwards
         if len(config.list_nodes(path[:-1])) == 0:
             config.delete(path[:-1])
 
 def migrate_ripng(config, path, interface):
     path = path + ['ripng']
     if config.exists(path):
         new_base = ['protocols', 'ripng', 'interface']
         config.set(new_base)
         config.set_tag(new_base)
         config.copy(path, new_base + [interface])
         config.delete(path)
 
         # if "ipv6 ripng" was the only setting, we can clean out the empty
         # ip node afterwards
         if len(config.list_nodes(path[:-1])) == 0:
             config.delete(path[:-1])
 
-if __name__ == '__main__':
-    if len(argv) < 2:
-        print("Must specify file name!")
-        exit(1)
-
-    file_name = argv[1]
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
-
+def migrate(config: ConfigTree) -> None:
     #
     # Migrate "interface ethernet eth0 ip ospf" to "protocols ospf interface eth0"
     #
     for type in config.list_nodes(['interfaces']):
         for interface in config.list_nodes(['interfaces', type]):
             ip_base = ['interfaces', type, interface, 'ip']
             ipv6_base = ['interfaces', type, interface, 'ipv6']
             migrate_rip(config, ip_base, interface)
             migrate_ripng(config, ipv6_base, interface)
             migrate_ospf(config, ip_base, interface)
             migrate_ospfv3(config, ipv6_base, interface)
 
             vif_path = ['interfaces', type, interface, 'vif']
             if config.exists(vif_path):
                 for vif in config.list_nodes(vif_path):
                     vif_ip_base = vif_path + [vif, 'ip']
                     vif_ipv6_base = vif_path + [vif, 'ipv6']
                     ifname = f'{interface}.{vif}'
 
                     migrate_rip(config, vif_ip_base, ifname)
                     migrate_ripng(config, vif_ipv6_base, ifname)
                     migrate_ospf(config, vif_ip_base, ifname)
                     migrate_ospfv3(config, vif_ipv6_base, ifname)
 
 
             vif_s_path = ['interfaces', type, interface, 'vif-s']
             if config.exists(vif_s_path):
                 for vif_s in config.list_nodes(vif_s_path):
                     vif_s_ip_base = vif_s_path + [vif_s, 'ip']
                     vif_s_ipv6_base = vif_s_path + [vif_s, 'ipv6']
 
                     # vif-c interfaces MUST be migrated before their parent vif-s
                     # interface as the migrate_*() functions delete the path!
                     vif_c_path = ['interfaces', type, interface, 'vif-s', vif_s, 'vif-c']
                     if config.exists(vif_c_path):
                         for vif_c in config.list_nodes(vif_c_path):
                             vif_c_ip_base = vif_c_path + [vif_c, 'ip']
                             vif_c_ipv6_base = vif_c_path + [vif_c, 'ipv6']
                             ifname = f'{interface}.{vif_s}.{vif_c}'
 
                             migrate_rip(config, vif_c_ip_base, ifname)
                             migrate_ripng(config, vif_c_ipv6_base, ifname)
                             migrate_ospf(config, vif_c_ip_base, ifname)
                             migrate_ospfv3(config, vif_c_ipv6_base, ifname)
 
 
                     ifname = f'{interface}.{vif_s}'
                     migrate_rip(config, vif_s_ip_base, ifname)
                     migrate_ripng(config, vif_s_ipv6_base, ifname)
                     migrate_ospf(config, vif_s_ip_base, ifname)
                     migrate_ospfv3(config, vif_s_ipv6_base, ifname)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/interfaces/24-to-25 b/src/migration-scripts/interfaces/24-to-25
old mode 100755
new mode 100644
index 8fd79ecc6..9f8cc80ec
--- a/src/migration-scripts/interfaces/24-to-25
+++ b/src/migration-scripts/interfaces/24-to-25
@@ -1,60 +1,41 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # A VTI interface also requires an IPSec configuration - VyOS 1.2 supported
 # having a VTI interface in the CLI but no IPSec configuration - drop VTI
 # configuration if this is the case for VyOS 1.4
 
-import sys
 from vyos.configtree import ConfigTree
 
-if __name__ == '__main__':
-    if len(sys.argv) < 2:
-        print("Must specify file name!")
-        sys.exit(1)
-
-    file_name = sys.argv[1]
-
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
+def migrate(config: ConfigTree) -> None:
     base = ['interfaces', 'vti']
     if not config.exists(base):
         # Nothing to do
-        sys.exit(0)
+        return
 
     ipsec_base = ['vpn', 'ipsec', 'site-to-site', 'peer']
     for interface in config.list_nodes(base):
         found = False
         if config.exists(ipsec_base):
             for peer in config.list_nodes(ipsec_base):
                 if config.exists(ipsec_base + [peer, 'vti', 'bind']):
                     tmp = config.return_value(ipsec_base + [peer, 'vti', 'bind'])
                     if tmp == interface:
                         # Interface was found and we no longer need to search
                         # for it in our IPSec peers
                         found = True
                         break
         if not found:
             config.delete(base + [interface])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/interfaces/25-to-26 b/src/migration-scripts/interfaces/25-to-26
old mode 100755
new mode 100644
index 9aa6ea5e3..7a4032d10
--- a/src/migration-scripts/interfaces/25-to-26
+++ b/src/migration-scripts/interfaces/25-to-26
@@ -1,387 +1,368 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021-2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Migrate Wireguard to store keys in CLI
 # Migrate EAPoL to PKI configuration
 
 import os
-import sys
 
 from vyos.configtree import ConfigTree
 from vyos.pki import CERT_BEGIN
 from vyos.pki import load_certificate
 from vyos.pki import load_crl
 from vyos.pki import load_dh_parameters
 from vyos.pki import load_private_key
 from vyos.pki import encode_certificate
 from vyos.pki import encode_dh_parameters
 from vyos.pki import encode_private_key
 from vyos.pki import verify_crl
 from vyos.utils.process import run
 
 def wrapped_pem_to_config_value(pem):
     out = []
     for line in pem.strip().split("\n"):
         if not line or line.startswith("-----") or line[0] == '#':
             continue
         out.append(line)
     return "".join(out)
 
 def read_file_for_pki(config_auth_path):
     full_path = os.path.join(AUTH_DIR, config_auth_path)
     output = None
 
     if os.path.isfile(full_path):
         if not os.access(full_path, os.R_OK):
             run(f'sudo chmod 644 {full_path}')
 
         with open(full_path, 'r') as f:
             output = f.read()
 
     return output
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 AUTH_DIR = '/config/auth'
 pki_base = ['pki']
 
-# OpenVPN
-base = ['interfaces', 'openvpn']
+def migrate(config: ConfigTree) -> None:
+    # OpenVPN
+    base = ['interfaces', 'openvpn']
+
+    if config.exists(base):
+        for interface in config.list_nodes(base):
+            x509_base = base + [interface, 'tls']
+            pki_name = f'openvpn_{interface}'
+
+            if config.exists(base + [interface, 'shared-secret-key-file']):
+                if not config.exists(pki_base + ['openvpn', 'shared-secret']):
+                    config.set(pki_base + ['openvpn', 'shared-secret'])
+                    config.set_tag(pki_base + ['openvpn', 'shared-secret'])
+
+                key_file = config.return_value(base + [interface, 'shared-secret-key-file'])
+                key = read_file_for_pki(key_file)
+                key_pki_name = f'{pki_name}_shared'
+
+                if key:
+                    config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key))
+                    config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1')
+                    config.set(base + [interface, 'shared-secret-key'], value=key_pki_name)
+                else:
+                    print(f'Failed to migrate shared-secret-key on openvpn interface {interface}')
+
+                config.delete(base + [interface, 'shared-secret-key-file'])
+
+            if not config.exists(base + [interface, 'tls']):
+                continue
+
+            if config.exists(base + [interface, 'tls', 'auth-file']):
+                if not config.exists(pki_base + ['openvpn', 'shared-secret']):
+                    config.set(pki_base + ['openvpn', 'shared-secret'])
+                    config.set_tag(pki_base + ['openvpn', 'shared-secret'])
+
+                key_file = config.return_value(base + [interface, 'tls', 'auth-file'])
+                key = read_file_for_pki(key_file)
+                key_pki_name = f'{pki_name}_auth'
+
+                if key:
+                    config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key))
+                    config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1')
+                    config.set(base + [interface, 'tls', 'auth-key'], value=key_pki_name)
+                else:
+                    print(f'Failed to migrate auth-key on openvpn interface {interface}')
+
+                config.delete(base + [interface, 'tls', 'auth-file'])
+
+            if config.exists(base + [interface, 'tls', 'crypt-file']):
+                if not config.exists(pki_base + ['openvpn', 'shared-secret']):
+                    config.set(pki_base + ['openvpn', 'shared-secret'])
+                    config.set_tag(pki_base + ['openvpn', 'shared-secret'])
+
+                key_file = config.return_value(base + [interface, 'tls', 'crypt-file'])
+                key = read_file_for_pki(key_file)
+                key_pki_name = f'{pki_name}_crypt'
+
+                if key:
+                    config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key))
+                    config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1')
+                    config.set(base + [interface, 'tls', 'crypt-key'], value=key_pki_name)
+                else:
+                    print(f'Failed to migrate crypt-key on openvpn interface {interface}')
+
+                config.delete(base + [interface, 'tls', 'crypt-file'])
+
+            ca_certs = {}
+
+            if config.exists(x509_base + ['ca-cert-file']):
+                if not config.exists(pki_base + ['ca']):
+                    config.set(pki_base + ['ca'])
+                    config.set_tag(pki_base + ['ca'])
+
+                cert_file = config.return_value(x509_base + ['ca-cert-file'])
+                cert_path = os.path.join(AUTH_DIR, cert_file)
+
+                if os.path.isfile(cert_path):
+                    if not os.access(cert_path, os.R_OK):
+                        run(f'sudo chmod 644 {cert_path}')
+
+                    with open(cert_path, 'r') as f:
+                        certs_str = f.read()
+                        certs_data = certs_str.split(CERT_BEGIN)
+                        index = 1
+                        for cert_data in certs_data[1:]:
+                            cert = load_certificate(CERT_BEGIN + cert_data, wrap_tags=False)
+
+                            if cert:
+                                ca_certs[f'{pki_name}_{index}'] = cert
+                                cert_pem = encode_certificate(cert)
+                                config.set(pki_base + ['ca', f'{pki_name}_{index}', 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+                                config.set(x509_base + ['ca-certificate'], value=f'{pki_name}_{index}', replace=False)
+                            else:
+                                print(f'Failed to migrate CA certificate on openvpn interface {interface}')
+
+                            index += 1
+                else:
+                    print(f'Failed to migrate CA certificate on openvpn interface {interface}')
+
+                config.delete(x509_base + ['ca-cert-file'])
+
+            if config.exists(x509_base + ['crl-file']):
+                if not config.exists(pki_base + ['ca']):
+                    config.set(pki_base + ['ca'])
+                    config.set_tag(pki_base + ['ca'])
+
+                crl_file = config.return_value(x509_base + ['crl-file'])
+                crl_path = os.path.join(AUTH_DIR, crl_file)
+                crl = None
+                crl_ca_name = None
+
+                if os.path.isfile(crl_path):
+                    if not os.access(crl_path, os.R_OK):
+                        run(f'sudo chmod 644 {crl_path}')
+
+                    with open(crl_path, 'r') as f:
+                        crl_data = f.read()
+                        crl = load_crl(crl_data, wrap_tags=False)
+
+                        for ca_name, ca_cert in ca_certs.items():
+                            if verify_crl(crl, ca_cert):
+                                crl_ca_name = ca_name
+                                break
+
+                if crl and crl_ca_name:
+                    crl_pem = encode_certificate(crl)
+                    config.set(pki_base + ['ca', crl_ca_name, 'crl'], value=wrapped_pem_to_config_value(crl_pem))
+                else:
+                    print(f'Failed to migrate CRL on openvpn interface {interface}')
+
+                config.delete(x509_base + ['crl-file'])
+
+            if config.exists(x509_base + ['cert-file']):
+                if not config.exists(pki_base + ['certificate']):
+                    config.set(pki_base + ['certificate'])
+                    config.set_tag(pki_base + ['certificate'])
+
+                cert_file = config.return_value(x509_base + ['cert-file'])
+                cert_path = os.path.join(AUTH_DIR, cert_file)
+                cert = None
+
+                if os.path.isfile(cert_path):
+                    if not os.access(cert_path, os.R_OK):
+                        run(f'sudo chmod 644 {cert_path}')
+
+                    with open(cert_path, 'r') as f:
+                        cert_data = f.read()
+                        cert = load_certificate(cert_data, wrap_tags=False)
+
+                if cert:
+                    cert_pem = encode_certificate(cert)
+                    config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+                    config.set(x509_base + ['certificate'], value=pki_name)
+                else:
+                    print(f'Failed to migrate certificate on openvpn interface {interface}')
+
+                config.delete(x509_base + ['cert-file'])
+
+            if config.exists(x509_base + ['key-file']):
+                key_file = config.return_value(x509_base + ['key-file'])
+                key_path = os.path.join(AUTH_DIR, key_file)
+                key = None
+
+                if os.path.isfile(key_path):
+                    if not os.access(key_path, os.R_OK):
+                        run(f'sudo chmod 644 {key_path}')
+
+                    with open(key_path, 'r') as f:
+                        key_data = f.read()
+                        key = load_private_key(key_data, passphrase=None, wrap_tags=False)
+
+                if key:
+                    key_pem = encode_private_key(key, passphrase=None)
+                    config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
+                else:
+                    print(f'Failed to migrate private key on openvpn interface {interface}')
+
+                config.delete(x509_base + ['key-file'])
+
+            if config.exists(x509_base + ['dh-file']):
+                if not config.exists(pki_base + ['dh']):
+                    config.set(pki_base + ['dh'])
+                    config.set_tag(pki_base + ['dh'])
+
+                dh_file = config.return_value(x509_base + ['dh-file'])
+                dh_path = os.path.join(AUTH_DIR, dh_file)
+                dh = None
+
+                if os.path.isfile(dh_path):
+                    if not os.access(dh_path, os.R_OK):
+                        run(f'sudo chmod 644 {dh_path}')
+
+                    with open(dh_path, 'r') as f:
+                        dh_data = f.read()
+                        dh = load_dh_parameters(dh_data, wrap_tags=False)
+
+                if dh:
+                    dh_pem = encode_dh_parameters(dh)
+                    config.set(pki_base + ['dh', pki_name, 'parameters'], value=wrapped_pem_to_config_value(dh_pem))
+                    config.set(x509_base + ['dh-params'], value=pki_name)
+                else:
+                    print(f'Failed to migrate DH parameters on openvpn interface {interface}')
+
+                config.delete(x509_base + ['dh-file'])
+
+    # Wireguard
+    base = ['interfaces', 'wireguard']
+
+    if config.exists(base):
+        for interface in config.list_nodes(base):
+            private_key_path = base + [interface, 'private-key']
+
+            key_file = 'default'
+            if config.exists(private_key_path):
+                key_file = config.return_value(private_key_path)
+
+            full_key_path = f'/config/auth/wireguard/{key_file}/private.key'
+
+            if not os.path.exists(full_key_path):
+                print(f'Could not find wireguard private key for migration on interface "{interface}"')
+                continue
+
+            with open(full_key_path, 'r') as f:
+                key_data = f.read().strip()
+                config.set(private_key_path, value=key_data)
+
+            for peer in config.list_nodes(base + [interface, 'peer']):
+                config.rename(base + [interface, 'peer', peer, 'pubkey'], 'public-key')
+
+    # Ethernet EAPoL
+    base = ['interfaces', 'ethernet']
+
+    if config.exists(base):
+        for interface in config.list_nodes(base):
+            if not config.exists(base + [interface, 'eapol']):
+                continue
+
+            x509_base = base + [interface, 'eapol']
+            pki_name = f'eapol_{interface}'
+
+            if config.exists(x509_base + ['ca-cert-file']):
+                if not config.exists(pki_base + ['ca']):
+                    config.set(pki_base + ['ca'])
+                    config.set_tag(pki_base + ['ca'])
+
+                cert_file = config.return_value(x509_base + ['ca-cert-file'])
+                cert_path = os.path.join(AUTH_DIR, cert_file)
+                cert = None
+
+                if os.path.isfile(cert_path):
+                    if not os.access(cert_path, os.R_OK):
+                        run(f'sudo chmod 644 {cert_path}')
+
+                    with open(cert_path, 'r') as f:
+                        cert_data = f.read()
+                        cert = load_certificate(cert_data, wrap_tags=False)
+
+                if cert:
+                    cert_pem = encode_certificate(cert)
+                    config.set(pki_base + ['ca', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+                    config.set(x509_base + ['ca-certificate'], value=pki_name)
+                else:
+                    print(f'Failed to migrate CA certificate on eapol config for interface {interface}')
+
+                config.delete(x509_base + ['ca-cert-file'])
 
-if config.exists(base):
-    for interface in config.list_nodes(base):
-        x509_base = base + [interface, 'tls']
-        pki_name = f'openvpn_{interface}'
+            if config.exists(x509_base + ['cert-file']):
+                if not config.exists(pki_base + ['certificate']):
+                    config.set(pki_base + ['certificate'])
+                    config.set_tag(pki_base + ['certificate'])
 
-        if config.exists(base + [interface, 'shared-secret-key-file']):
-            if not config.exists(pki_base + ['openvpn', 'shared-secret']):
-                config.set(pki_base + ['openvpn', 'shared-secret'])
-                config.set_tag(pki_base + ['openvpn', 'shared-secret'])
+                cert_file = config.return_value(x509_base + ['cert-file'])
+                cert_path = os.path.join(AUTH_DIR, cert_file)
+                cert = None
 
-            key_file = config.return_value(base + [interface, 'shared-secret-key-file'])
-            key = read_file_for_pki(key_file)
-            key_pki_name = f'{pki_name}_shared'
+                if os.path.isfile(cert_path):
+                    if not os.access(cert_path, os.R_OK):
+                        run(f'sudo chmod 644 {cert_path}')
 
-            if key:
-                config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key))
-                config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1')
-                config.set(base + [interface, 'shared-secret-key'], value=key_pki_name)
-            else:
-                print(f'Failed to migrate shared-secret-key on openvpn interface {interface}')
+                    with open(cert_path, 'r') as f:
+                        cert_data = f.read()
+                        cert = load_certificate(cert_data, wrap_tags=False)
 
-            config.delete(base + [interface, 'shared-secret-key-file'])
+                if cert:
+                    cert_pem = encode_certificate(cert)
+                    config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+                    config.set(x509_base + ['certificate'], value=pki_name)
+                else:
+                    print(f'Failed to migrate certificate on eapol config for interface {interface}')
 
-        if not config.exists(base + [interface, 'tls']):
-            continue
+                config.delete(x509_base + ['cert-file'])
 
-        if config.exists(base + [interface, 'tls', 'auth-file']):
-            if not config.exists(pki_base + ['openvpn', 'shared-secret']):
-                config.set(pki_base + ['openvpn', 'shared-secret'])
-                config.set_tag(pki_base + ['openvpn', 'shared-secret'])
-
-            key_file = config.return_value(base + [interface, 'tls', 'auth-file'])
-            key = read_file_for_pki(key_file)
-            key_pki_name = f'{pki_name}_auth'
-
-            if key:
-                config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key))
-                config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1')
-                config.set(base + [interface, 'tls', 'auth-key'], value=key_pki_name)
-            else:
-                print(f'Failed to migrate auth-key on openvpn interface {interface}')
-
-            config.delete(base + [interface, 'tls', 'auth-file'])
-
-        if config.exists(base + [interface, 'tls', 'crypt-file']):
-            if not config.exists(pki_base + ['openvpn', 'shared-secret']):
-                config.set(pki_base + ['openvpn', 'shared-secret'])
-                config.set_tag(pki_base + ['openvpn', 'shared-secret'])
-
-            key_file = config.return_value(base + [interface, 'tls', 'crypt-file'])
-            key = read_file_for_pki(key_file)
-            key_pki_name = f'{pki_name}_crypt'
-
-            if key:
-                config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key))
-                config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1')
-                config.set(base + [interface, 'tls', 'crypt-key'], value=key_pki_name)
-            else:
-                print(f'Failed to migrate crypt-key on openvpn interface {interface}')
-
-            config.delete(base + [interface, 'tls', 'crypt-file'])
-
-        ca_certs = {}
-
-        if config.exists(x509_base + ['ca-cert-file']):
-            if not config.exists(pki_base + ['ca']):
-                config.set(pki_base + ['ca'])
-                config.set_tag(pki_base + ['ca'])
-
-            cert_file = config.return_value(x509_base + ['ca-cert-file'])
-            cert_path = os.path.join(AUTH_DIR, cert_file)
-
-            if os.path.isfile(cert_path):
-                if not os.access(cert_path, os.R_OK):
-                    run(f'sudo chmod 644 {cert_path}')
-
-                with open(cert_path, 'r') as f:
-                    certs_str = f.read()
-                    certs_data = certs_str.split(CERT_BEGIN)
-                    index = 1
-                    for cert_data in certs_data[1:]:
-                        cert = load_certificate(CERT_BEGIN + cert_data, wrap_tags=False)
-
-                        if cert:
-                            ca_certs[f'{pki_name}_{index}'] = cert
-                            cert_pem = encode_certificate(cert)
-                            config.set(pki_base + ['ca', f'{pki_name}_{index}', 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
-                            config.set(x509_base + ['ca-certificate'], value=f'{pki_name}_{index}', replace=False)
-                        else:
-                            print(f'Failed to migrate CA certificate on openvpn interface {interface}')
-
-                        index += 1
-            else:
-                print(f'Failed to migrate CA certificate on openvpn interface {interface}')
-
-            config.delete(x509_base + ['ca-cert-file'])
-
-        if config.exists(x509_base + ['crl-file']):
-            if not config.exists(pki_base + ['ca']):
-                config.set(pki_base + ['ca'])
-                config.set_tag(pki_base + ['ca'])
-
-            crl_file = config.return_value(x509_base + ['crl-file'])
-            crl_path = os.path.join(AUTH_DIR, crl_file)
-            crl = None
-            crl_ca_name = None
-
-            if os.path.isfile(crl_path):
-                if not os.access(crl_path, os.R_OK):
-                    run(f'sudo chmod 644 {crl_path}')
-
-                with open(crl_path, 'r') as f:
-                    crl_data = f.read()
-                    crl = load_crl(crl_data, wrap_tags=False)
-
-                    for ca_name, ca_cert in ca_certs.items():
-                        if verify_crl(crl, ca_cert):
-                            crl_ca_name = ca_name
-                            break
-
-            if crl and crl_ca_name:
-                crl_pem = encode_certificate(crl)
-                config.set(pki_base + ['ca', crl_ca_name, 'crl'], value=wrapped_pem_to_config_value(crl_pem))
-            else:
-                print(f'Failed to migrate CRL on openvpn interface {interface}')
-
-            config.delete(x509_base + ['crl-file'])
-
-        if config.exists(x509_base + ['cert-file']):
-            if not config.exists(pki_base + ['certificate']):
-                config.set(pki_base + ['certificate'])
-                config.set_tag(pki_base + ['certificate'])
-
-            cert_file = config.return_value(x509_base + ['cert-file'])
-            cert_path = os.path.join(AUTH_DIR, cert_file)
-            cert = None
-
-            if os.path.isfile(cert_path):
-                if not os.access(cert_path, os.R_OK):
-                    run(f'sudo chmod 644 {cert_path}')
-
-                with open(cert_path, 'r') as f:
-                    cert_data = f.read()
-                    cert = load_certificate(cert_data, wrap_tags=False)
-
-            if cert:
-                cert_pem = encode_certificate(cert)
-                config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
-                config.set(x509_base + ['certificate'], value=pki_name)
-            else:
-                print(f'Failed to migrate certificate on openvpn interface {interface}')
-
-            config.delete(x509_base + ['cert-file'])
-
-        if config.exists(x509_base + ['key-file']):
-            key_file = config.return_value(x509_base + ['key-file'])
-            key_path = os.path.join(AUTH_DIR, key_file)
-            key = None
-
-            if os.path.isfile(key_path):
-                if not os.access(key_path, os.R_OK):
-                    run(f'sudo chmod 644 {key_path}')
-
-                with open(key_path, 'r') as f:
-                    key_data = f.read()
-                    key = load_private_key(key_data, passphrase=None, wrap_tags=False)
-
-            if key:
-                key_pem = encode_private_key(key, passphrase=None)
-                config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
-            else:
-                print(f'Failed to migrate private key on openvpn interface {interface}')
-
-            config.delete(x509_base + ['key-file'])
-
-        if config.exists(x509_base + ['dh-file']):
-            if not config.exists(pki_base + ['dh']):
-                config.set(pki_base + ['dh'])
-                config.set_tag(pki_base + ['dh'])
-
-            dh_file = config.return_value(x509_base + ['dh-file'])
-            dh_path = os.path.join(AUTH_DIR, dh_file)
-            dh = None
-
-            if os.path.isfile(dh_path):
-                if not os.access(dh_path, os.R_OK):
-                    run(f'sudo chmod 644 {dh_path}')
-
-                with open(dh_path, 'r') as f:
-                    dh_data = f.read()
-                    dh = load_dh_parameters(dh_data, wrap_tags=False)
-
-            if dh:
-                dh_pem = encode_dh_parameters(dh)
-                config.set(pki_base + ['dh', pki_name, 'parameters'], value=wrapped_pem_to_config_value(dh_pem))
-                config.set(x509_base + ['dh-params'], value=pki_name)
-            else:
-                print(f'Failed to migrate DH parameters on openvpn interface {interface}')
-
-            config.delete(x509_base + ['dh-file'])
-
-# Wireguard
-base = ['interfaces', 'wireguard']
-
-if config.exists(base):
-    for interface in config.list_nodes(base):
-        private_key_path = base + [interface, 'private-key']
-
-        key_file = 'default'
-        if config.exists(private_key_path):
-            key_file = config.return_value(private_key_path)
-
-        full_key_path = f'/config/auth/wireguard/{key_file}/private.key'
+            if config.exists(x509_base + ['key-file']):
+                key_file = config.return_value(x509_base + ['key-file'])
+                key_path = os.path.join(AUTH_DIR, key_file)
+                key = None
+
+                if os.path.isfile(key_path):
+                    if not os.access(key_path, os.R_OK):
+                        run(f'sudo chmod 644 {key_path}')
 
-        if not os.path.exists(full_key_path):
-            print(f'Could not find wireguard private key for migration on interface "{interface}"')
-            continue
-
-        with open(full_key_path, 'r') as f:
-            key_data = f.read().strip()
-            config.set(private_key_path, value=key_data)
-
-        for peer in config.list_nodes(base + [interface, 'peer']):
-            config.rename(base + [interface, 'peer', peer, 'pubkey'], 'public-key')
+                    with open(key_path, 'r') as f:
+                        key_data = f.read()
+                        key = load_private_key(key_data, passphrase=None, wrap_tags=False)
 
-# Ethernet EAPoL
-base = ['interfaces', 'ethernet']
-
-if config.exists(base):
-    for interface in config.list_nodes(base):
-        if not config.exists(base + [interface, 'eapol']):
-            continue
+                if key:
+                    key_pem = encode_private_key(key, passphrase=None)
+                    config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
+                else:
+                    print(f'Failed to migrate private key on eapol config for interface {interface}')
 
-        x509_base = base + [interface, 'eapol']
-        pki_name = f'eapol_{interface}'
-
-        if config.exists(x509_base + ['ca-cert-file']):
-            if not config.exists(pki_base + ['ca']):
-                config.set(pki_base + ['ca'])
-                config.set_tag(pki_base + ['ca'])
-
-            cert_file = config.return_value(x509_base + ['ca-cert-file'])
-            cert_path = os.path.join(AUTH_DIR, cert_file)
-            cert = None
-
-            if os.path.isfile(cert_path):
-                if not os.access(cert_path, os.R_OK):
-                    run(f'sudo chmod 644 {cert_path}')
-
-                with open(cert_path, 'r') as f:
-                    cert_data = f.read()
-                    cert = load_certificate(cert_data, wrap_tags=False)
-
-            if cert:
-                cert_pem = encode_certificate(cert)
-                config.set(pki_base + ['ca', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
-                config.set(x509_base + ['ca-certificate'], value=pki_name)
-            else:
-                print(f'Failed to migrate CA certificate on eapol config for interface {interface}')
-
-            config.delete(x509_base + ['ca-cert-file'])
-
-        if config.exists(x509_base + ['cert-file']):
-            if not config.exists(pki_base + ['certificate']):
-                config.set(pki_base + ['certificate'])
-                config.set_tag(pki_base + ['certificate'])
-
-            cert_file = config.return_value(x509_base + ['cert-file'])
-            cert_path = os.path.join(AUTH_DIR, cert_file)
-            cert = None
-
-            if os.path.isfile(cert_path):
-                if not os.access(cert_path, os.R_OK):
-                    run(f'sudo chmod 644 {cert_path}')
-
-                with open(cert_path, 'r') as f:
-                    cert_data = f.read()
-                    cert = load_certificate(cert_data, wrap_tags=False)
-
-            if cert:
-                cert_pem = encode_certificate(cert)
-                config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
-                config.set(x509_base + ['certificate'], value=pki_name)
-            else:
-                print(f'Failed to migrate certificate on eapol config for interface {interface}')
-
-            config.delete(x509_base + ['cert-file'])
-
-        if config.exists(x509_base + ['key-file']):
-            key_file = config.return_value(x509_base + ['key-file'])
-            key_path = os.path.join(AUTH_DIR, key_file)
-            key = None
-
-            if os.path.isfile(key_path):
-                if not os.access(key_path, os.R_OK):
-                    run(f'sudo chmod 644 {key_path}')
-
-                with open(key_path, 'r') as f:
-                    key_data = f.read()
-                    key = load_private_key(key_data, passphrase=None, wrap_tags=False)
-
-            if key:
-                key_pem = encode_private_key(key, passphrase=None)
-                config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
-            else:
-                print(f'Failed to migrate private key on eapol config for interface {interface}')
-
-            config.delete(x509_base + ['key-file'])
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+                config.delete(x509_base + ['key-file'])
diff --git a/src/migration-scripts/interfaces/26-to-27 b/src/migration-scripts/interfaces/26-to-27
old mode 100755
new mode 100644
index 429ab650f..3f58de02c
--- a/src/migration-scripts/interfaces/26-to-27
+++ b/src/migration-scripts/interfaces/26-to-27
@@ -1,52 +1,35 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T4384: pppoe: replace default-route CLI option with common CLI nodes already
 #        present for DHCP
 
-from sys import argv
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-base = ['interfaces', 'pppoe']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    exit(0)
-
-for ifname in config.list_nodes(base):
-    tmp_config = base + [ifname, 'default-route']
-    if config.exists(tmp_config):
-        # Retrieve current config value
-        value = config.return_value(tmp_config)
-        # Delete old Config node
-        config.delete(tmp_config)
-        if value == 'none':
-            config.set(base + [ifname, 'no-default-route'])
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    base = ['interfaces', 'pppoe']
+
+    if not config.exists(base):
+        return
+
+    for ifname in config.list_nodes(base):
+        tmp_config = base + [ifname, 'default-route']
+        if config.exists(tmp_config):
+            # Retrieve current config value
+            value = config.return_value(tmp_config)
+            # Delete old Config node
+            config.delete(tmp_config)
+            if value == 'none':
+                config.set(base + [ifname, 'no-default-route'])
diff --git a/src/migration-scripts/interfaces/27-to-28 b/src/migration-scripts/interfaces/27-to-28
old mode 100755
new mode 100644
index 9f5e93b5f..eb9363e39
--- a/src/migration-scripts/interfaces/27-to-28
+++ b/src/migration-scripts/interfaces/27-to-28
@@ -1,48 +1,29 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T4995: pppoe, wwan, sstpc-client rename "authentication user" CLI node
 #        to "authentication username"
 
-from sys import argv
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
-for type in ['pppoe', 'sstpc-client', 'wwam']:
-    base = ['interfaces', type]
-    if not config.exists(base):
-        continue
-    for interface in config.list_nodes(base):
-        auth_base = base + [interface, 'authentication', 'user']
-        if config.exists(auth_base):
-            config.rename(auth_base, 'username')
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    for type in ['pppoe', 'sstpc-client', 'wwam']:
+        base = ['interfaces', type]
+        if not config.exists(base):
+            continue
+        for interface in config.list_nodes(base):
+            auth_base = base + [interface, 'authentication', 'user']
+            if config.exists(auth_base):
+                config.rename(auth_base, 'username')
diff --git a/src/migration-scripts/interfaces/28-to-29 b/src/migration-scripts/interfaces/28-to-29
old mode 100755
new mode 100644
index 0437977dc..886d49e2c
--- a/src/migration-scripts/interfaces/28-to-29
+++ b/src/migration-scripts/interfaces/28-to-29
@@ -1,52 +1,35 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5034: tunnel: rename "multicast enable" CLI node to "enable-multicast"
 #        valueless node.
 
-from sys import argv
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['interfaces', 'tunnel']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    exit(0)
-
-for ifname in config.list_nodes(base):
-    multicast_base = base + [ifname, 'multicast']
-    if config.exists(multicast_base):
-        tmp = config.return_value(multicast_base)
-        print(tmp)
-        # Delete old Config node
-        config.delete(multicast_base)
-        if tmp == 'enable':
-            config.set(base + [ifname, 'enable-multicast'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
+
+    for ifname in config.list_nodes(base):
+        multicast_base = base + [ifname, 'multicast']
+        if config.exists(multicast_base):
+            tmp = config.return_value(multicast_base)
+            print(tmp)
+            # Delete old Config node
+            config.delete(multicast_base)
+            if tmp == 'enable':
+                config.set(base + [ifname, 'enable-multicast'])
diff --git a/src/migration-scripts/interfaces/29-to-30 b/src/migration-scripts/interfaces/29-to-30
old mode 100755
new mode 100644
index 80aad1d44..7b32d871e
--- a/src/migration-scripts/interfaces/29-to-30
+++ b/src/migration-scripts/interfaces/29-to-30
@@ -1,47 +1,30 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5286: remove XDP support in favour of VPP
 
-from sys import argv
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 supports_xdp = ['bonding', 'ethernet']
-config = ConfigTree(config_file)
-
-for if_type in supports_xdp:
-    base = ['interfaces', if_type]
-    if not config.exists(base):
-        continue
-    for interface in config.list_nodes(base):
-        if_base = base + [interface]
-        if config.exists(if_base + ['xdp']):
-            config.delete(if_base + ['xdp'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    for if_type in supports_xdp:
+        base = ['interfaces', if_type]
+        if not config.exists(base):
+            continue
+        for interface in config.list_nodes(base):
+            if_base = base + [interface]
+            if config.exists(if_base + ['xdp']):
+                config.delete(if_base + ['xdp'])
diff --git a/src/migration-scripts/interfaces/3-to-4 b/src/migration-scripts/interfaces/3-to-4
old mode 100755
new mode 100644
index c7fd7d01d..4e56200e1
--- a/src/migration-scripts/interfaces/3-to-4
+++ b/src/migration-scripts/interfaces/3-to-4
@@ -1,97 +1,93 @@
-#!/usr/bin/env python3
+# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Change syntax of wireless interfaces
 # Migrate boolean nodes to valueless
 
-import sys
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['interfaces', 'wireless']
 
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
-else:
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
     for wifi in config.list_nodes(base):
         # as converting a node to bool is always the same, we can script it
         to_bool_nodes = ['capabilities ht 40MHz-incapable',
                          'capabilities ht auto-powersave',
                          'capabilities ht delayed-block-ack',
                          'capabilities ht dsss-cck-40',
                          'capabilities ht greenfield',
                          'capabilities ht ldpc',
                          'capabilities ht lsig-protection',
                          'capabilities ht stbc tx',
                          'capabilities require-ht',
                          'capabilities require-vht',
                          'capabilities vht antenna-pattern-fixed',
                          'capabilities vht ldpc',
                          'capabilities vht stbc tx',
                          'capabilities vht tx-powersave',
                          'capabilities vht vht-cf',
                          'expunge-failing-stations',
                          'isolate-stations']
 
         for node in to_bool_nodes:
             if config.exists(base + [wifi, node]):
                 tmp = config.return_value(base + [wifi, node])
                 # delete old node
                 config.delete(base + [wifi, node])
                 # set new node if it was enabled
                 if tmp == 'true':
                     # OLD CLI used camel casing in 40MHz-incapable which is
                     # not supported in the new backend. Convert all to lower-case
                     config.set(base + [wifi, node.lower()])
 
         # Remove debug node
         if config.exists(base + [wifi, 'debug']):
             config.delete(base + [wifi, 'debug'])
 
         # RADIUS servers
         if config.exists(base + [wifi, 'security', 'wpa', 'radius-server']):
             for server in config.list_nodes(base + [wifi, 'security', 'wpa', 'radius-server']):
                 base_server = base + [wifi, 'security', 'wpa', 'radius-server', server]
 
                 # Migrate RADIUS shared secret
                 if config.exists(base_server + ['secret']):
                     key = config.return_value(base_server + ['secret'])
                     # write new configuration node
                     config.set(base + [wifi, 'security', 'wpa', 'radius', 'server', server, 'key'], value=key)
                     # format as tag node
                     config.set_tag(base + [wifi, 'security', 'wpa', 'radius', 'server'])
 
                 # Migrate RADIUS port
                 if config.exists(base_server + ['port']):
                     port = config.return_value(base_server + ['port'])
                     # write new configuration node
                     config.set(base + [wifi, 'security', 'wpa', 'radius', 'server', server, 'port'], value=port)
                     # format as tag node
                     config.set_tag(base + [wifi, 'security', 'wpa', 'radius', 'server'])
 
                 # Migrate RADIUS accounting
                 if config.exists(base_server + ['accounting']):
                     port = config.return_value(base_server + ['accounting'])
                     # write new configuration node
                     config.set(base + [wifi, 'security', 'wpa', 'radius', 'server', server, 'accounting'])
                     # format as tag node
                     config.set_tag(base + [wifi, 'security', 'wpa', 'radius', 'server'])
 
             # delete old radius-server nodes
             config.delete(base + [wifi, 'security', 'wpa', 'radius-server'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/interfaces/30-to-31 b/src/migration-scripts/interfaces/30-to-31
old mode 100755
new mode 100644
index 894106ef4..7e509dd86
--- a/src/migration-scripts/interfaces/30-to-31
+++ b/src/migration-scripts/interfaces/30-to-31
@@ -1,71 +1,56 @@
 #!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021-2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# Deletes Wireguard peers if they have the same public key as the router has.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
+
+# T5254: Fixed changing ethernet when it is a bond member
 
 import json
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 from vyos.ifconfig import EthernetIf
 from vyos.ifconfig import BondIf
 from vyos.utils.dict import dict_to_paths_values
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
+base = ['interfaces', 'bonding']
 
-file_name = argv[1]
-with open(file_name, 'r') as f:
-    config_file = f.read()
-    base = ['interfaces', 'bonding']
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-for bond in config.list_nodes(base):
-    member_base = base + [bond, 'member', 'interface']
-    if config.exists(member_base):
-        for interface in config.return_values(member_base):
-            if_base = ['interfaces', 'ethernet', interface]
-            if config.exists(if_base):
-                config_ethernet = json.loads(config.get_subtree(if_base).to_json())
-                eth_dict_paths = dict_to_paths_values(config_ethernet)
-                for option_path, option_value in eth_dict_paths.items():
-                    # If option is allowed for changing then continue
-                    converted_path = option_path.replace('-','_')
-                    if converted_path in EthernetIf.get_bond_member_allowed_options():
-                        continue
-                    # if option is inherited from bond then continue
-                    if converted_path in BondIf.get_inherit_bond_options():
-                        continue
-                    option_path_list = option_path.split('.')
-                    config.delete(if_base + option_path_list)
-                    del option_path_list[-1]
-                    # delete empty node from config
-                    while len(option_path_list) > 0:
-                        if config.list_nodes(if_base + option_path_list):
-                            break
+    for bond in config.list_nodes(base):
+        member_base = base + [bond, 'member', 'interface']
+        if config.exists(member_base):
+            for interface in config.return_values(member_base):
+                if_base = ['interfaces', 'ethernet', interface]
+                if config.exists(if_base):
+                    config_ethernet = json.loads(config.get_subtree(if_base).to_json())
+                    eth_dict_paths = dict_to_paths_values(config_ethernet)
+                    for option_path, option_value in eth_dict_paths.items():
+                        # If option is allowed for changing then continue
+                        converted_path = option_path.replace('-','_')
+                        if converted_path in EthernetIf.get_bond_member_allowed_options():
+                            continue
+                        # if option is inherited from bond then continue
+                        if converted_path in BondIf.get_inherit_bond_options():
+                            continue
+                        option_path_list = option_path.split('.')
                         config.delete(if_base + option_path_list)
                         del option_path_list[-1]
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+                        # delete empty node from config
+                        while len(option_path_list) > 0:
+                            if config.list_nodes(if_base + option_path_list):
+                                break
+                            config.delete(if_base + option_path_list)
+                            del option_path_list[-1]
diff --git a/src/migration-scripts/interfaces/31-to-32 b/src/migration-scripts/interfaces/31-to-32
old mode 100755
new mode 100644
index 0fc27b70a..24077ed24
--- a/src/migration-scripts/interfaces/31-to-32
+++ b/src/migration-scripts/interfaces/31-to-32
@@ -1,55 +1,37 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
+
 # T5671: change port to IANA assigned default port
 # T5759: change default MTU 1450 -> 1500
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['interfaces', 'vxlan']
 
-config = ConfigTree(config_file)
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-for vxlan in config.list_nodes(base):
-    if config.exists(base + [vxlan, 'external']):
-        config.delete(base + [vxlan, 'external'])
-        config.set(base + [vxlan, 'parameters', 'external'])
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-    if not config.exists(base + [vxlan, 'port']):
-        config.set(base + [vxlan, 'port'], value='8472')
+    for vxlan in config.list_nodes(base):
+        if config.exists(base + [vxlan, 'external']):
+            config.delete(base + [vxlan, 'external'])
+            config.set(base + [vxlan, 'parameters', 'external'])
 
-    if not config.exists(base + [vxlan, 'mtu']):
-        config.set(base + [vxlan, 'mtu'], value='1450')
+        if not config.exists(base + [vxlan, 'port']):
+            config.set(base + [vxlan, 'port'], value='8472')
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+        if not config.exists(base + [vxlan, 'mtu']):
+            config.set(base + [vxlan, 'mtu'], value='1450')
diff --git a/src/migration-scripts/interfaces/32-to-33 b/src/migration-scripts/interfaces/32-to-33
old mode 100755
new mode 100644
index caf588474..c7b1c5b36
--- a/src/migration-scripts/interfaces/32-to-33
+++ b/src/migration-scripts/interfaces/32-to-33
@@ -1,57 +1,40 @@
 #!/usr/bin/env python3
 #
 # Copyright (C) 2024 VyOS maintainers and contributors
 #
 # This program is free software; you can redistribute it and/or modify
 # it under the terms of the GNU General Public License version 2 or later as
 # published by the Free Software Foundation.
 #
 # This program 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 for more details.
 #
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 # T6318: WiFi country-code should be set system-wide instead of per-device
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['interfaces', 'wireless']
 
-config = ConfigTree(config_file)
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-installed = False
-for interface in config.list_nodes(base):
-    cc_path = base + [interface, 'country-code']
-    if config.exists(cc_path):
-        tmp = config.return_value(cc_path)
-        config.delete(cc_path)
-
-        # There can be only ONE wireless country-code per device, everything
-        # else makes no sense as a WIFI router can not operate in two
-        # different countries
-        if not installed:
-            config.set(['system', 'wireless', 'country-code'], value=tmp)
-            installed = True
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    installed = False
+    for interface in config.list_nodes(base):
+        cc_path = base + [interface, 'country-code']
+        if config.exists(cc_path):
+            tmp = config.return_value(cc_path)
+            config.delete(cc_path)
+
+            # There can be only ONE wireless country-code per device, everything
+            # else makes no sense as a WIFI router can not operate in two
+            # different countries
+            if not installed:
+                config.set(['system', 'wireless', 'country-code'], value=tmp)
+                installed = True
diff --git a/src/migration-scripts/interfaces/4-to-5 b/src/migration-scripts/interfaces/4-to-5
old mode 100755
new mode 100644
index 68d81e846..93fa7c393
--- a/src/migration-scripts/interfaces/4-to-5
+++ b/src/migration-scripts/interfaces/4-to-5
@@ -1,112 +1,106 @@
-#!/usr/bin/env python3
+# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # De-nest PPPoE interfaces
 # Migrate boolean nodes to valueless
 
-import sys
 from vyos.configtree import ConfigTree
 
 def migrate_dialer(config, tree, intf):
     for pppoe in config.list_nodes(tree):
         # assemble string, 0 -> pppoe0
         new_base = ['interfaces', 'pppoe']
         pppoe_base = new_base + ['pppoe' + pppoe]
         config.set(new_base)
         # format as tag node to avoid loading problems
         config.set_tag(new_base)
 
         # Copy the entire old node to the new one before migrating individual
         # parts
         config.copy(tree + [pppoe], pppoe_base)
 
         # Instead of letting the user choose between auto and none
         # where auto is default, it makes more sesne to just offer
         # an option to disable the default behavior (declutter CLI)
         if config.exists(pppoe_base + ['name-server']):
             tmp = config.return_value(pppoe_base + ['name-server'])
             if tmp == "none":
                 config.set(pppoe_base + ['no-peer-dns'])
             config.delete(pppoe_base + ['name-server'])
 
         # Migrate user-id and password nodes under an 'authentication'
         # node
         if config.exists(pppoe_base + ['user-id']):
             user = config.return_value(pppoe_base + ['user-id'])
             config.set(pppoe_base + ['authentication', 'user'], value=user)
             config.delete(pppoe_base + ['user-id'])
 
         if config.exists(pppoe_base + ['password']):
             pwd = config.return_value(pppoe_base + ['password'])
             config.set(pppoe_base + ['authentication', 'password'], value=pwd)
             config.delete(pppoe_base + ['password'])
 
         # remove enable-ipv6 node and rather place it under ipv6 node
         if config.exists(pppoe_base + ['enable-ipv6']):
             config.set(pppoe_base + ['ipv6', 'enable'])
             config.delete(pppoe_base + ['enable-ipv6'])
 
         # Source interface migration
         config.set(pppoe_base + ['source-interface'], value=intf)
 
         # Remove IPv6 router-advert nodes as this makes no sense on a
         # client diale rinterface to send RAs back into the network
         # https://vyos.dev/T2055
         ipv6_ra = pppoe_base + ['ipv6', 'router-advert']
         if config.exists(ipv6_ra):
             config.delete(ipv6_ra)
 
-
-if __name__ == '__main__':
-    if len(sys.argv) < 2:
-        print("Must specify file name!")
-        exit(1)
-
-    file_name = sys.argv[1]
-
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
+def migrate(config: ConfigTree) -> None:
     pppoe_links = ['bonding', 'ethernet']
 
     for link_type in pppoe_links:
         if not config.exists(['interfaces', link_type]):
             continue
 
         for interface in config.list_nodes(['interfaces', link_type]):
             # check if PPPoE exists
             base_if = ['interfaces', link_type, interface]
             pppoe_if = base_if + ['pppoe']
             if config.exists(pppoe_if):
                 for dialer in  config.list_nodes(pppoe_if):
                     migrate_dialer(config, pppoe_if, interface)
 
                 # Delete old PPPoE interface
                 config.delete(pppoe_if)
 
             # bail out early if there are no VLAN interfaces to migrate
             if not config.exists(base_if + ['vif']):
                 continue
 
             # Migrate PPPoE interfaces attached to a VLAN
             for vlan in config.list_nodes(base_if + ['vif']):
                 vlan_if = base_if + ['vif', vlan]
                 pppoe_if = vlan_if + ['pppoe']
                 if config.exists(pppoe_if):
                     for dialer in  config.list_nodes(pppoe_if):
                         intf = "{}.{}".format(interface, vlan)
                         migrate_dialer(config, pppoe_if, intf)
 
                     # Delete old PPPoE interface
                     config.delete(pppoe_if)
 
                     # Add interface description that this is required for PPPoE
                     if not config.exists(vlan_if + ['description']):
                         config.set(vlan_if + ['description'], value='PPPoE link interface')
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/interfaces/5-to-6 b/src/migration-scripts/interfaces/5-to-6
old mode 100755
new mode 100644
index 9d9a49c2d..44c32ba63
--- a/src/migration-scripts/interfaces/5-to-6
+++ b/src/migration-scripts/interfaces/5-to-6
@@ -1,133 +1,114 @@
-#!/usr/bin/env python3
+# Copyright 202-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020-2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Migrate IPv6 router advertisments from a nested interface configuration to
 # a denested "service router-advert"
 
-import sys
 from vyos.configtree import ConfigTree
 
 def copy_rtradv(c, old_base, interface):
     base = ['service', 'router-advert', 'interface']
 
     if c.exists(old_base):
         if not c.exists(base):
             c.set(base)
             c.set_tag(base)
 
         # take the old node as a whole and copy it to new new path,
         # additional migrations will be done afterwards
         new_base = base + [interface]
         c.copy(old_base, new_base)
         c.delete(old_base)
 
         # cur-hop-limit has been renamed to hop-limit
         if c.exists(new_base + ['cur-hop-limit']):
             c.rename(new_base + ['cur-hop-limit'], 'hop-limit')
 
         bool_cleanup = ['managed-flag', 'other-config-flag']
         for bool in bool_cleanup:
             if c.exists(new_base + [bool]):
                 tmp = c.return_value(new_base + [bool])
                 c.delete(new_base + [bool])
                 if tmp == 'true':
                     c.set(new_base + [bool])
 
         # max/min interval moved to subnode
         intervals = ['max-interval', 'min-interval']
         for interval in intervals:
             if c.exists(new_base + [interval]):
                 tmp = c.return_value(new_base + [interval])
                 c.delete(new_base + [interval])
                 min_max = interval.split('-')[0]
                 c.set(new_base + ['interval', min_max], value=tmp)
 
         # cleanup boolean nodes in individual route
         route_base = new_base + ['route']
         if c.exists(route_base):
             for route in config.list_nodes(route_base):
                 if c.exists(route_base + [route, 'remove-route']):
                     tmp = c.return_value(route_base + [route, 'remove-route'])
                     c.delete(route_base + [route, 'remove-route'])
                     if tmp == 'false':
                        c.set(route_base + [route, 'no-remove-route'])
 
         # cleanup boolean nodes in individual prefix
         prefix_base = new_base + ['prefix']
         if c.exists(prefix_base):
             for prefix in config.list_nodes(prefix_base):
                 if c.exists(prefix_base + [prefix, 'autonomous-flag']):
                     tmp = c.return_value(prefix_base + [prefix, 'autonomous-flag'])
                     c.delete(prefix_base + [prefix, 'autonomous-flag'])
                     if tmp == 'false':
                         c.set(prefix_base + [prefix, 'no-autonomous-flag'])
 
                 if c.exists(prefix_base + [prefix, 'on-link-flag']):
                     tmp = c.return_value(prefix_base + [prefix, 'on-link-flag'])
                     c.delete(prefix_base + [prefix, 'on-link-flag'])
                     if tmp == 'true':
                         c.set(prefix_base + [prefix, 'on-link-flag'])
 
         # router advertisement can be individually disabled per interface
         # the node has been renamed from send-advert {true | false} to no-send-advert
         if c.exists(new_base + ['send-advert']):
             tmp = c.return_value(new_base + ['send-advert'])
             c.delete(new_base + ['send-advert'])
             if tmp == 'false':
                 c.set(new_base + ['no-send-advert'])
 
         # link-mtu advertisement was formerly disabled by setting its value to 0
         # ... this makes less sense - if it should not be send, just do not
         # configure it
         if c.exists(new_base + ['link-mtu']):
             tmp = c.return_value(new_base + ['link-mtu'])
             if tmp == '0':
                 c.delete(new_base + ['link-mtu'])
 
-if __name__ == '__main__':
-    if len(sys.argv) < 2:
-        print("Must specify file name!")
-        exit(1)
-
-    file_name = sys.argv[1]
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
-
+def migrate(config: ConfigTree) -> None:
     # list all individual interface types like dummy, ethernet and so on
     for if_type in config.list_nodes(['interfaces']):
         base_if_type = ['interfaces', if_type]
 
         # for every individual interface we need to check if there is an
         # ipv6 ra configured ... and also for every VIF (VLAN) interface
         for intf in config.list_nodes(base_if_type):
             old_base = base_if_type + [intf, 'ipv6', 'router-advert']
             copy_rtradv(config, old_base, intf)
 
             vif_base = base_if_type + [intf, 'vif']
             if config.exists(vif_base):
                 for vif in config.list_nodes(vif_base):
                     old_base = vif_base + [vif, 'ipv6', 'router-advert']
                     vlan_name = f'{intf}.{vif}'
                     copy_rtradv(config, old_base, vlan_name)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/interfaces/6-to-7 b/src/migration-scripts/interfaces/6-to-7
old mode 100755
new mode 100644
index 49b853d90..e60121eec
--- a/src/migration-scripts/interfaces/6-to-7
+++ b/src/migration-scripts/interfaces/6-to-7
@@ -1,63 +1,45 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Remove network provider name from CLI and rather use provider APN from CLI
 
-import sys
 from vyos.configtree import ConfigTree
 
-if __name__ == '__main__':
-    if len(sys.argv) < 2:
-        print("Must specify file name!")
-        exit(1)
-
-    file_name = sys.argv[1]
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
+def migrate(config: ConfigTree) -> None:
     base = ['interfaces', 'wirelessmodem']
 
     if not config.exists(base):
         # Nothing to do
-        sys.exit(0)
+        return
 
     # list all individual wwan/wireless modem interfaces
     for i in config.list_nodes(base):
         iface = base + [i]
 
         # only three carries have been supported in the past, thus
         # this will be fairly simple \o/ - and only one (AT&T) did
         # configure an APN
         if config.exists(iface + ['network']):
             network = config.return_value(iface + ['network'])
             if network == "att":
                 apn = 'isp.cingular'
                 config.set(iface + ['apn'], value=apn)
 
             config.delete(iface + ['network'])
 
         # synchronize DNS configuration with PPPoE interfaces to have a
         # uniform CLI experience
         if config.exists(iface + ['no-dns']):
             config.rename(iface + ['no-dns'], 'no-peer-dns')
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/interfaces/7-to-8 b/src/migration-scripts/interfaces/7-to-8
old mode 100755
new mode 100644
index 9343a48a8..43ae320ab
--- a/src/migration-scripts/interfaces/7-to-8
+++ b/src/migration-scripts/interfaces/7-to-8
@@ -1,77 +1,59 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020-2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Split WireGuard endpoint into address / port nodes to make use of common
 # validators
 
 import os
 
-from sys import exit, argv
 from vyos.configtree import ConfigTree
 from vyos.utils.permission import chown
 from vyos.utils.permission import chmod_750
 
 def migrate_default_keys():
     kdir = r'/config/auth/wireguard'
     if os.path.exists(f'{kdir}/private.key') and not os.path.exists(f'{kdir}/default/private.key'):
         location = f'{kdir}/default'
         if not os.path.exists(location):
             os.makedirs(location)
 
         chown(location, 'root', 'vyattacfg')
         chmod_750(location)
         os.rename(f'{kdir}/private.key', f'{location}/private.key')
         os.rename(f'{kdir}/public.key', f'{location}/public.key')
 
-if __name__ == '__main__':
-    if len(argv) < 2:
-        print("Must specify file name!")
-        exit(1)
-
-    file_name = argv[1]
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
+def migrate(config: ConfigTree) -> None:
     base = ['interfaces', 'wireguard']
 
     migrate_default_keys()
 
     if not config.exists(base):
         # Nothing to do
-        exit(0)
+        return
 
     # list all individual wireguard interface isntance
     for i in config.list_nodes(base):
         iface = base + [i]
         for peer in config.list_nodes(iface + ['peer']):
             base_peer = iface + ['peer', peer]
             if config.exists(base_peer + ['endpoint']):
                 endpoint = config.return_value(base_peer + ['endpoint'])
                 address = endpoint.split(':')[0]
                 port = endpoint.split(':')[1]
                 # delete old node
                 config.delete(base_peer + ['endpoint'])
                 # setup new nodes
                 config.set(base_peer + ['address'], value=address)
                 config.set(base_peer + ['port'], value=port)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/interfaces/8-to-9 b/src/migration-scripts/interfaces/8-to-9
old mode 100755
new mode 100644
index 960962be7..bae1b34fa
--- a/src/migration-scripts/interfaces/8-to-9
+++ b/src/migration-scripts/interfaces/8-to-9
@@ -1,52 +1,33 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Rename link nodes to source-interface for the following interface types:
 # - vxlan
 # - pseudo-ethernet
 
-from sys import exit, argv
 from vyos.configtree import ConfigTree
 
-if __name__ == '__main__':
-    if len(argv) < 2:
-        print("Must specify file name!")
-        exit(1)
-
-    file_name = argv[1]
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
-
+def migrate(config: ConfigTree) -> None:
     for if_type in ['vxlan', 'pseudo-ethernet']:
         base = ['interfaces', if_type]
         if not config.exists(base):
             # Nothing to do
             continue
 
         # list all individual interface isntance
         for i in config.list_nodes(base):
             iface = base + [i]
             if config.exists(iface + ['link']):
                 config.rename(iface + ['link'], 'source-interface')
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/interfaces/9-to-10 b/src/migration-scripts/interfaces/9-to-10
old mode 100755
new mode 100644
index e9b8cb784..cdfd7d432
--- a/src/migration-scripts/interfaces/9-to-10
+++ b/src/migration-scripts/interfaces/9-to-10
@@ -1,64 +1,45 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - rename CLI node 'dhcpv6-options delgate' to 'dhcpv6-options prefix-delegation
 #   interface'
 # - rename CLI node 'interface-id' for prefix-delegation to 'address' as it
 #   represents the local interface IPv6 address assigned by DHCPv6-PD
 
-from sys import exit, argv
 from vyos.configtree import ConfigTree
 
-if __name__ == '__main__':
-    if len(argv) < 2:
-        print("Must specify file name!")
-        exit(1)
-
-    file_name = argv[1]
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
-
+def migrate(config: ConfigTree) -> None:
     for intf_type in config.list_nodes(['interfaces']):
         for intf in config.list_nodes(['interfaces', intf_type]):
             # cache current config tree
             base_path = ['interfaces', intf_type, intf, 'dhcpv6-options',
                           'delegate']
 
             if config.exists(base_path):
                 # cache new config tree
                 new_path = ['interfaces', intf_type, intf, 'dhcpv6-options',
                              'prefix-delegation']
                 if not config.exists(new_path):
                     config.set(new_path)
 
                 # copy to new node
                 config.copy(base_path, new_path + ['interface'])
 
                 # rename interface-id to address
                 for interface in config.list_nodes(new_path + ['interface']):
                     config.rename(new_path + ['interface', interface, 'interface-id'], 'address')
 
                 # delete old noe
                 config.delete(base_path)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/ipoe-server/1-to-2 b/src/migration-scripts/ipoe-server/1-to-2
old mode 100755
new mode 100644
index 6a7111541..034eacb10
--- a/src/migration-scripts/ipoe-server/1-to-2
+++ b/src/migration-scripts/ipoe-server/1-to-2
@@ -1,114 +1,94 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - T4703: merge vlan-id and vlan-range to vlan CLI node
 # L2|L3 -> l2|l3
 # mac-address -> mac
 # network-mode -> mode
 
 # - changed cli of all named pools
 # - moved gateway-address from pool to global configuration with / netmask
 #   gateway can exist without pool if radius is used
 #   and Framed-ip-address is transmited
 # - There are several gateway-addresses in ipoe
 # - default-pool by migration.
 #       1. The first pool that contains next-poll.
 #       2. Else, the first pool in the list
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['service', 'ipoe-server']
 
-if not config.exists(base):
-    exit(0)
-
-if config.exists(base + ['authentication', 'interface']):
-    for interface in config.list_nodes(base + ['authentication', 'interface']):
-        config.rename(base + ['authentication', 'interface', interface, 'mac-address'], 'mac')
-
-        mac_base = base + ['authentication', 'interface', interface, 'mac']
-        for mac in config.list_nodes(mac_base):
-            vlan_config = mac_base + [mac, 'vlan-id']
-            if config.exists(vlan_config):
-                config.rename(vlan_config, 'vlan')
-
-for interface in config.list_nodes(base + ['interface']):
-    base_path = base + ['interface', interface]
-    for vlan in ['vlan-id', 'vlan-range']:
-        if config.exists(base_path + [vlan]):
-            for tmp in config.return_values(base_path + [vlan]):
-                config.set(base_path + ['vlan'], value=tmp, replace=False)
-            config.delete(base_path + [vlan])
-
-    if config.exists(base_path + ['network-mode']):
-        tmp = config.return_value(base_path + ['network-mode'])
-        config.delete(base_path + ['network-mode'])
-        # Change L2|L3 to lower case l2|l3
-        config.set(base_path + ['mode'], value=tmp.lower())
-
-pool_base = base + ['client-ip-pool']
-if config.exists(pool_base):
-    default_pool = ''
-    gateway = ''
-
-    #named pool migration
-    namedpools_base = pool_base + ['name']
-
-    for pool_name in config.list_nodes(namedpools_base):
-        pool_path = namedpools_base + [pool_name]
-        if config.exists(pool_path + ['subnet']):
-            subnet = config.return_value(pool_path + ['subnet'])
-            config.set(pool_base + [pool_name, 'range'], value=subnet, replace=False)
-            # Get netmask from subnet
-            mask = subnet.split("/")[1]
-        if config.exists(pool_path + ['next-pool']):
-            next_pool = config.return_value(pool_path + ['next-pool'])
-            config.set(pool_base + [pool_name, 'next-pool'], value=next_pool)
-            if not default_pool:
-                default_pool = pool_name
-        if config.exists(pool_path + ['gateway-address']) and mask:
-            gateway = f'{config.return_value(pool_path + ["gateway-address"])}/{mask}'
-            config.set(base + ['gateway-address'], value=gateway, replace=False)
-
-    if not default_pool and config.list_nodes(namedpools_base):
-        default_pool = config.list_nodes(namedpools_base)[0]
-
-    config.delete(namedpools_base)
-
-    if default_pool:
-        config.set(base + ['default-pool'], value=default_pool)
-    # format as tag node
-    config.set_tag(pool_base)
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
+
+    if config.exists(base + ['authentication', 'interface']):
+        for interface in config.list_nodes(base + ['authentication', 'interface']):
+            config.rename(base + ['authentication', 'interface', interface, 'mac-address'], 'mac')
+
+            mac_base = base + ['authentication', 'interface', interface, 'mac']
+            for mac in config.list_nodes(mac_base):
+                vlan_config = mac_base + [mac, 'vlan-id']
+                if config.exists(vlan_config):
+                    config.rename(vlan_config, 'vlan')
+
+    for interface in config.list_nodes(base + ['interface']):
+        base_path = base + ['interface', interface]
+        for vlan in ['vlan-id', 'vlan-range']:
+            if config.exists(base_path + [vlan]):
+                for tmp in config.return_values(base_path + [vlan]):
+                    config.set(base_path + ['vlan'], value=tmp, replace=False)
+                config.delete(base_path + [vlan])
+
+        if config.exists(base_path + ['network-mode']):
+            tmp = config.return_value(base_path + ['network-mode'])
+            config.delete(base_path + ['network-mode'])
+            # Change L2|L3 to lower case l2|l3
+            config.set(base_path + ['mode'], value=tmp.lower())
+
+    pool_base = base + ['client-ip-pool']
+    if config.exists(pool_base):
+        default_pool = ''
+        gateway = ''
+
+        #named pool migration
+        namedpools_base = pool_base + ['name']
+
+        for pool_name in config.list_nodes(namedpools_base):
+            pool_path = namedpools_base + [pool_name]
+            if config.exists(pool_path + ['subnet']):
+                subnet = config.return_value(pool_path + ['subnet'])
+                config.set(pool_base + [pool_name, 'range'], value=subnet, replace=False)
+                # Get netmask from subnet
+                mask = subnet.split("/")[1]
+            if config.exists(pool_path + ['next-pool']):
+                next_pool = config.return_value(pool_path + ['next-pool'])
+                config.set(pool_base + [pool_name, 'next-pool'], value=next_pool)
+                if not default_pool:
+                    default_pool = pool_name
+            if config.exists(pool_path + ['gateway-address']) and mask:
+                gateway = f'{config.return_value(pool_path + ["gateway-address"])}/{mask}'
+                config.set(base + ['gateway-address'], value=gateway, replace=False)
+
+        if not default_pool and config.list_nodes(namedpools_base):
+            default_pool = config.list_nodes(namedpools_base)[0]
+
+        config.delete(namedpools_base)
+
+        if default_pool:
+            config.set(base + ['default-pool'], value=default_pool)
+        # format as tag node
+        config.set_tag(pool_base)
diff --git a/src/migration-scripts/ipoe-server/2-to-3 b/src/migration-scripts/ipoe-server/2-to-3
old mode 100755
new mode 100644
index 0909315a8..dcd15e595
--- a/src/migration-scripts/ipoe-server/2-to-3
+++ b/src/migration-scripts/ipoe-server/2-to-3
@@ -1,58 +1,40 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Migrating to named ipv6 pools
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
+base = ['service', 'ipoe-server']
+pool_base = base + ['client-ipv6-pool']
 
-file_name = argv[1]
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+    if not config.exists(pool_base):
+        return
 
-config = ConfigTree(config_file)
-base = ['service', 'ipoe-server']
-pool_base = base + ['client-ipv6-pool']
-if not config.exists(base):
-    exit(0)
-
-if not config.exists(pool_base):
-    exit(0)
-
-ipv6_pool_name = 'ipv6-pool'
-config.copy(pool_base, pool_base + [ipv6_pool_name])
-
-if config.exists(pool_base + ['prefix']):
-    config.delete(pool_base + ['prefix'])
-    config.set(base + ['default-ipv6-pool'], value=ipv6_pool_name)
-if config.exists(pool_base + ['delegate']):
-    config.delete(pool_base + ['delegate'])
-
-# format as tag node
-config.set_tag(pool_base)
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    ipv6_pool_name = 'ipv6-pool'
+    config.copy(pool_base, pool_base + [ipv6_pool_name])
+
+    if config.exists(pool_base + ['prefix']):
+        config.delete(pool_base + ['prefix'])
+        config.set(base + ['default-ipv6-pool'], value=ipv6_pool_name)
+    if config.exists(pool_base + ['delegate']):
+        config.delete(pool_base + ['delegate'])
+
+    # format as tag node
+    config.set_tag(pool_base)
diff --git a/src/migration-scripts/ipsec/10-to-11 b/src/migration-scripts/ipsec/10-to-11
old mode 100755
new mode 100644
index 509216267..6c4ccb553
--- a/src/migration-scripts/ipsec/10-to-11
+++ b/src/migration-scripts/ipsec/10-to-11
@@ -1,83 +1,63 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-from sys import argv
-from sys import exit
+# T4916: Rewrite IPsec peer authentication and psk migration
 
 from vyos.configtree import ConfigTree
 
-
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['vpn', 'ipsec']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-# PEER changes
-if config.exists(base + ['site-to-site', 'peer']):
-    for peer in config.list_nodes(base + ['site-to-site', 'peer']):
-        peer_base = base + ['site-to-site', 'peer', peer]
-
-        # replace: 'ipsec site-to-site peer <tag> authentication pre-shared-secret xxx'
-        #       => 'ipsec authentication psk <tag> secret xxx'
-        if config.exists(peer_base + ['authentication', 'pre-shared-secret']):
-            tmp = config.return_value(peer_base + ['authentication', 'pre-shared-secret'])
-            config.delete(peer_base + ['authentication', 'pre-shared-secret'])
-            config.set(base + ['authentication', 'psk', peer, 'secret'], value=tmp)
-            # format as tag node to avoid loading problems
-            config.set_tag(base + ['authentication', 'psk'])
-
-            # Get id's from peers for "ipsec auth psk <tag> id xxx"
-            if config.exists(peer_base + ['authentication', 'local-id']):
-                local_id = config.return_value(peer_base + ['authentication', 'local-id'])
-                config.set(base + ['authentication', 'psk', peer, 'id'], value=local_id, replace=False)
-            if config.exists(peer_base + ['authentication', 'remote-id']):
-                remote_id = config.return_value(peer_base + ['authentication', 'remote-id'])
-                config.set(base + ['authentication', 'psk', peer, 'id'], value=remote_id, replace=False)
-
-            if config.exists(peer_base + ['local-address']):
-                tmp = config.return_value(peer_base + ['local-address'])
-                config.set(base + ['authentication', 'psk', peer, 'id'], value=tmp, replace=False)
-            if config.exists(peer_base + ['remote-address']):
-                tmp = config.return_values(peer_base + ['remote-address'])
-                if tmp:
-                    for remote_addr in tmp:
-                        if remote_addr == 'any':
-                            remote_addr = '%any'
-                        config.set(base + ['authentication', 'psk', peer, 'id'], value=remote_addr, replace=False)
-
-            # get DHCP peer interface as psk dhcp-interface
-            if config.exists(peer_base + ['dhcp-interface']):
-                tmp = config.return_value(peer_base + ['dhcp-interface'])
-                config.set(base + ['authentication', 'psk', peer, 'dhcp-interface'], value=tmp)
-
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    # PEER changes
+    if config.exists(base + ['site-to-site', 'peer']):
+        for peer in config.list_nodes(base + ['site-to-site', 'peer']):
+            peer_base = base + ['site-to-site', 'peer', peer]
+
+            # replace: 'ipsec site-to-site peer <tag> authentication pre-shared-secret xxx'
+            #       => 'ipsec authentication psk <tag> secret xxx'
+            if config.exists(peer_base + ['authentication', 'pre-shared-secret']):
+                tmp = config.return_value(peer_base + ['authentication', 'pre-shared-secret'])
+                config.delete(peer_base + ['authentication', 'pre-shared-secret'])
+                config.set(base + ['authentication', 'psk', peer, 'secret'], value=tmp)
+                # format as tag node to avoid loading problems
+                config.set_tag(base + ['authentication', 'psk'])
+
+                # Get id's from peers for "ipsec auth psk <tag> id xxx"
+                if config.exists(peer_base + ['authentication', 'local-id']):
+                    local_id = config.return_value(peer_base + ['authentication', 'local-id'])
+                    config.set(base + ['authentication', 'psk', peer, 'id'], value=local_id, replace=False)
+                if config.exists(peer_base + ['authentication', 'remote-id']):
+                    remote_id = config.return_value(peer_base + ['authentication', 'remote-id'])
+                    config.set(base + ['authentication', 'psk', peer, 'id'], value=remote_id, replace=False)
+
+                if config.exists(peer_base + ['local-address']):
+                    tmp = config.return_value(peer_base + ['local-address'])
+                    config.set(base + ['authentication', 'psk', peer, 'id'], value=tmp, replace=False)
+                if config.exists(peer_base + ['remote-address']):
+                    tmp = config.return_values(peer_base + ['remote-address'])
+                    if tmp:
+                        for remote_addr in tmp:
+                            if remote_addr == 'any':
+                                remote_addr = '%any'
+                            config.set(base + ['authentication', 'psk', peer, 'id'], value=remote_addr, replace=False)
+
+                # get DHCP peer interface as psk dhcp-interface
+                if config.exists(peer_base + ['dhcp-interface']):
+                    tmp = config.return_value(peer_base + ['dhcp-interface'])
+                    config.set(base + ['authentication', 'psk', peer, 'dhcp-interface'], value=tmp)
diff --git a/src/migration-scripts/ipsec/11-to-12 b/src/migration-scripts/ipsec/11-to-12
old mode 100755
new mode 100644
index 4833d0876..fc65f1825
--- a/src/migration-scripts/ipsec/11-to-12
+++ b/src/migration-scripts/ipsec/11-to-12
@@ -1,51 +1,31 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Remove legacy ipsec.conf and ipsec.secrets - Not supported with swanctl
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['vpn', 'ipsec']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
 
-if config.exists(base + ['include-ipsec-conf']):
-    config.delete(base + ['include-ipsec-conf'])
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-if config.exists(base + ['include-ipsec-secrets']):
-    config.delete(base + ['include-ipsec-secrets'])
+    if config.exists(base + ['include-ipsec-conf']):
+        config.delete(base + ['include-ipsec-conf'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+    if config.exists(base + ['include-ipsec-secrets']):
+        config.delete(base + ['include-ipsec-secrets'])
diff --git a/src/migration-scripts/ipsec/12-to-13 b/src/migration-scripts/ipsec/12-to-13
old mode 100755
new mode 100644
index d90c70314..ffe766eb2
--- a/src/migration-scripts/ipsec/12-to-13
+++ b/src/migration-scripts/ipsec/12-to-13
@@ -1,57 +1,37 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Changed value of dead-peer-detection.action from hold to trap
 # Changed value of close-action from hold to trap and from restart to start
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['vpn', 'ipsec', 'ike-group']
-config = ConfigTree(config_file)
 
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-else:
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
     for ike_group in config.list_nodes(base):
         base_dpd_action = base + [ike_group, 'dead-peer-detection', 'action']
         base_close_action = base + [ike_group, 'close-action']
         if config.exists(base_dpd_action) and config.return_value(base_dpd_action) == 'hold':
             config.set(base_dpd_action, 'trap', replace=True)
         if config.exists(base_close_action):
             if config.return_value(base_close_action) == 'hold':
                 config.set(base_close_action, 'trap', replace=True)
             if config.return_value(base_close_action) == 'restart':
                 config.set(base_close_action, 'start', replace=True)
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
diff --git a/src/migration-scripts/ipsec/4-to-5 b/src/migration-scripts/ipsec/4-to-5
old mode 100755
new mode 100644
index 772d05787..a88a543d3
--- a/src/migration-scripts/ipsec/4-to-5
+++ b/src/migration-scripts/ipsec/4-to-5
@@ -1,47 +1,28 @@
-#!/usr/bin/env python3
+# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2019 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # log-modes have changed, keyword  all to any
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-ctree = ConfigTree(config_file)
-
-if not ctree.exists(['vpn', 'ipsec', 'logging','log-modes']):
-    # Nothing to do
-    sys.exit(0)
-else:
-  lmodes = ctree.return_values(['vpn', 'ipsec', 'logging','log-modes'])
-  for mode in lmodes:
-    if mode == 'all':
-      ctree.set(['vpn', 'ipsec', 'logging','log-modes'], value='any', replace=True)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(['vpn', 'ipsec', 'logging','log-modes']):
+        # Nothing to do
+        return
 
-  try:
-    open(file_name,'w').write(ctree.to_string())
-  except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+    lmodes = config.return_values(['vpn', 'ipsec', 'logging','log-modes'])
+    for mode in lmodes:
+        if mode == 'all':
+            config.set(['vpn', 'ipsec', 'logging','log-modes'], value='any', replace=True)
diff --git a/src/migration-scripts/ipsec/5-to-6 b/src/migration-scripts/ipsec/5-to-6
old mode 100755
new mode 100644
index 7d7c777c6..373428d61
--- a/src/migration-scripts/ipsec/5-to-6
+++ b/src/migration-scripts/ipsec/5-to-6
@@ -1,93 +1,73 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Remove deprecated strongSwan options from VyOS CLI
 # - vpn ipsec nat-traversal enable
 # - vpn ipsec nat-networks allowed-network
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['vpn', 'ipsec']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-# Delete CLI nodes whose config options got removed by strongSwan
-for cli_node in ['nat-traversal', 'nat-networks']:
-    if config.exists(base + [cli_node]):
-        config.delete(base + [cli_node])
-
-# Remove options only valid in Openswan
-if config.exists(base + ['site-to-site', 'peer']):
-    for peer in config.list_nodes(base + ['site-to-site', 'peer']):
-        if not config.exists(base + ['site-to-site', 'peer', peer, 'tunnel']):
-            continue
-        for tunnel in config.list_nodes(base + ['site-to-site', 'peer', peer, 'tunnel']):
-            # allow-public-networks - Sets a value in ipsec.conf that was only ever valid in Openswan on kernel 2.6
-            nat_networks = base + ['site-to-site', 'peer', peer, 'tunnel', tunnel, 'allow-nat-networks']
-            if config.exists(nat_networks):
-                config.delete(nat_networks)
-
-            # allow-nat-networks - Also sets a value only valid in Openswan
-            public_networks = base + ['site-to-site', 'peer', peer, 'tunnel', tunnel, 'allow-public-networks']
-            if config.exists(public_networks):
-                config.delete(public_networks)
-
-# Rename "logging log-level" and "logging log-modes" to something more human friendly
-log = base + ['logging']
-if config.exists(log):
-    config.rename(log, 'log')
-    log = base + ['log']
-
-log_level = log + ['log-level']
-if config.exists(log_level):
-    config.rename(log_level, 'level')
-
-log_mode = log + ['log-modes']
-if config.exists(log_mode):
-    config.rename(log_mode, 'subsystem')
-
-# Rename "ipsec-interfaces interface" to "interface"
-base_interfaces = base + ['ipsec-interfaces', 'interface']
-if config.exists(base_interfaces):
-    config.copy(base_interfaces, base + ['interface'])
-    config.delete(base + ['ipsec-interfaces'])
-
-# Remove deprecated "auto-update" option
-tmp = base + ['auto-update']
-if config.exists(tmp):
-    config.delete(tmp)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    # Delete CLI nodes whose config options got removed by strongSwan
+    for cli_node in ['nat-traversal', 'nat-networks']:
+        if config.exists(base + [cli_node]):
+            config.delete(base + [cli_node])
+
+    # Remove options only valid in Openswan
+    if config.exists(base + ['site-to-site', 'peer']):
+        for peer in config.list_nodes(base + ['site-to-site', 'peer']):
+            if not config.exists(base + ['site-to-site', 'peer', peer, 'tunnel']):
+                continue
+            for tunnel in config.list_nodes(base + ['site-to-site', 'peer', peer, 'tunnel']):
+                # allow-public-networks - Sets a value in ipsec.conf that was only ever valid in Openswan on kernel 2.6
+                nat_networks = base + ['site-to-site', 'peer', peer, 'tunnel', tunnel, 'allow-nat-networks']
+                if config.exists(nat_networks):
+                    config.delete(nat_networks)
+
+                # allow-nat-networks - Also sets a value only valid in Openswan
+                public_networks = base + ['site-to-site', 'peer', peer, 'tunnel', tunnel, 'allow-public-networks']
+                if config.exists(public_networks):
+                    config.delete(public_networks)
+
+    # Rename "logging log-level" and "logging log-modes" to something more human friendly
+    log = base + ['logging']
+    if config.exists(log):
+        config.rename(log, 'log')
+        log = base + ['log']
+
+    log_level = log + ['log-level']
+    if config.exists(log_level):
+        config.rename(log_level, 'level')
+
+    log_mode = log + ['log-modes']
+    if config.exists(log_mode):
+        config.rename(log_mode, 'subsystem')
+
+    # Rename "ipsec-interfaces interface" to "interface"
+    base_interfaces = base + ['ipsec-interfaces', 'interface']
+    if config.exists(base_interfaces):
+        config.copy(base_interfaces, base + ['interface'])
+        config.delete(base + ['ipsec-interfaces'])
+
+    # Remove deprecated "auto-update" option
+    tmp = base + ['auto-update']
+    if config.exists(tmp):
+        config.delete(tmp)
diff --git a/src/migration-scripts/ipsec/6-to-7 b/src/migration-scripts/ipsec/6-to-7
old mode 100755
new mode 100644
index f8b6de560..5679477c0
--- a/src/migration-scripts/ipsec/6-to-7
+++ b/src/migration-scripts/ipsec/6-to-7
@@ -1,169 +1,155 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021-2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Migrate /config/auth certificates and keys into PKI configuration
 
 import os
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 from vyos.pki import load_certificate
 from vyos.pki import load_crl
 from vyos.pki import load_private_key
 from vyos.pki import encode_certificate
 from vyos.pki import encode_private_key
 from vyos.utils.process import run
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 pki_base = ['pki']
 ipsec_site_base = ['vpn', 'ipsec', 'site-to-site', 'peer']
 
-config = ConfigTree(config_file)
-changes_made = False
-
 AUTH_DIR = '/config/auth'
 
 def wrapped_pem_to_config_value(pem):
     return "".join(pem.strip().split("\n")[1:-1])
 
-if config.exists(ipsec_site_base):
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(ipsec_site_base):
+        return
+
+    migration_needed = False
+    for peer in config.list_nodes(ipsec_site_base):
+        if config.exists(ipsec_site_base + [peer, 'authentication', 'x509']):
+            migration_needed = True
+            break
+
+    if not migration_needed:
+        return
+
     config.set(pki_base + ['ca'])
     config.set_tag(pki_base + ['ca'])
 
     config.set(pki_base + ['certificate'])
     config.set_tag(pki_base + ['certificate'])
 
     for peer in config.list_nodes(ipsec_site_base):
         if not config.exists(ipsec_site_base + [peer, 'authentication', 'x509']):
             continue
 
-        changes_made = True
-
         peer_x509_base = ipsec_site_base + [peer, 'authentication', 'x509']
         pki_name = 'peer_' + peer.replace(".", "-").replace("@", "")
 
         if config.exists(peer_x509_base + ['cert-file']):
             cert_file = config.return_value(peer_x509_base + ['cert-file'])
             cert_path = os.path.join(AUTH_DIR, cert_file)
             cert = None
 
             if os.path.isfile(cert_path):
                 if not os.access(cert_path, os.R_OK):
                     run(f'sudo chmod 644 {cert_path}')
 
                 with open(cert_path, 'r') as f:
                     cert_data = f.read()
                     cert = load_certificate(cert_data, wrap_tags=False)
 
             if cert:
                 cert_pem = encode_certificate(cert)
                 config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
                 config.set(peer_x509_base + ['certificate'], value=pki_name)
             else:
                 print(f'Failed to migrate certificate on peer "{peer}"')
 
             config.delete(peer_x509_base + ['cert-file'])
 
         if config.exists(peer_x509_base + ['ca-cert-file']):
             ca_cert_file = config.return_value(peer_x509_base + ['ca-cert-file'])
             ca_cert_path = os.path.join(AUTH_DIR, ca_cert_file)
             ca_cert = None
 
             if os.path.isfile(ca_cert_path):
                 if not os.access(ca_cert_path, os.R_OK):
                     run(f'sudo chmod 644 {ca_cert_path}')
 
                 with open(ca_cert_path, 'r') as f:
                     ca_cert_data = f.read()
                     ca_cert = load_certificate(ca_cert_data, wrap_tags=False)
 
             if ca_cert:
                 ca_cert_pem = encode_certificate(ca_cert)
                 config.set(pki_base + ['ca', pki_name, 'certificate'], value=wrapped_pem_to_config_value(ca_cert_pem))
                 config.set(peer_x509_base + ['ca-certificate'], value=pki_name)
             else:
                 print(f'Failed to migrate CA certificate on peer "{peer}"')
 
             config.delete(peer_x509_base + ['ca-cert-file'])
 
         if config.exists(peer_x509_base + ['crl-file']):
             crl_file = config.return_value(peer_x509_base + ['crl-file'])
             crl_path = os.path.join(AUTH_DIR, crl_file)
             crl = None
 
             if os.path.isfile(crl_path):
                 if not os.access(crl_path, os.R_OK):
                     run(f'sudo chmod 644 {crl_path}')
 
                 with open(crl_path, 'r') as f:
                     crl_data = f.read()
                     crl = load_crl(crl_data, wrap_tags=False)
 
             if crl:
                 crl_pem = encode_certificate(crl)
                 config.set(pki_base + ['ca', pki_name, 'crl'], value=wrapped_pem_to_config_value(crl_pem))
             else:
                 print(f'Failed to migrate CRL on peer "{peer}"')
 
             config.delete(peer_x509_base + ['crl-file'])
 
         if config.exists(peer_x509_base + ['key', 'file']):
             key_file = config.return_value(peer_x509_base + ['key', 'file'])
             key_passphrase = None
 
             if config.exists(peer_x509_base + ['key', 'password']):
                 key_passphrase = config.return_value(peer_x509_base + ['key', 'password'])
 
             key_path = os.path.join(AUTH_DIR, key_file)
             key = None
 
             if os.path.isfile(key_path):
                 if not os.access(key_path, os.R_OK):
                     run(f'sudo chmod 644 {key_path}')
 
                 with open(key_path, 'r') as f:
                     key_data = f.read()
                     key = load_private_key(key_data, passphrase=key_passphrase, wrap_tags=False)
 
             if key:
                 key_pem = encode_private_key(key, passphrase=key_passphrase)
                 config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
 
                 if key_passphrase:
                     config.set(pki_base + ['certificate', pki_name, 'private', 'password-protected'])
                     config.set(peer_x509_base + ['private-key-passphrase'], value=key_passphrase)
             else:
                 print(f'Failed to migrate private key on peer "{peer}"')
 
             config.delete(peer_x509_base + ['key'])
-
-if changes_made:
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/ipsec/7-to-8 b/src/migration-scripts/ipsec/7-to-8
old mode 100755
new mode 100644
index 9acc737d5..481f00d29
--- a/src/migration-scripts/ipsec/7-to-8
+++ b/src/migration-scripts/ipsec/7-to-8
@@ -1,124 +1,103 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Migrate rsa keys into PKI configuration
 
 import base64
 import os
 import struct
 
 from cryptography.hazmat.primitives.asymmetric import rsa
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 from vyos.pki import load_private_key
 from vyos.pki import encode_public_key
 from vyos.pki import encode_private_key
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 pki_base = ['pki']
 ipsec_site_base = ['vpn', 'ipsec', 'site-to-site', 'peer']
 rsa_keys_base = ['vpn', 'rsa-keys']
 
-config = ConfigTree(config_file)
-
 LOCAL_KEY_PATHS = ['/config/auth/', '/config/ipsec.d/rsa-keys/']
 
 def migrate_from_vyatta_key(data):
     data = base64.b64decode(data[2:])
     length = struct.unpack('B', data[:1])[0]
     e = int.from_bytes(data[1:1+length], 'big')
     n = int.from_bytes(data[1+length:], 'big')
     public_numbers = rsa.RSAPublicNumbers(e, n)
     return public_numbers.public_key()
 
 def wrapped_pem_to_config_value(pem):
     return "".join(pem.strip().split("\n")[1:-1])
 
 local_key_name = 'localhost'
 
-if config.exists(rsa_keys_base):
-    if not config.exists(pki_base + ['key-pair']):
-        config.set(pki_base + ['key-pair'])
-        config.set_tag(pki_base + ['key-pair'])
-
-    if config.exists(rsa_keys_base + ['local-key', 'file']):
-        local_file = config.return_value(rsa_keys_base + ['local-key', 'file'])
-        local_path = None
-        local_key = None
-
-        for path in LOCAL_KEY_PATHS:
-            full_path = os.path.join(path, local_file)
-            if os.path.exists(full_path):
-                local_path = full_path
-                break
-
-        if local_path:
-            with open(local_path, 'r') as f:
-                local_key_data = f.read()
-                local_key = load_private_key(local_key_data, wrap_tags=False)
-
-        if local_key:
-            local_key_pem = encode_private_key(local_key)
-            config.set(pki_base + ['key-pair', local_key_name, 'private', 'key'], value=wrapped_pem_to_config_value(local_key_pem))
-        else:
-            print('Failed to migrate local RSA key')
-
-    if config.exists(rsa_keys_base + ['rsa-key-name']):
-        for rsa_name in config.list_nodes(rsa_keys_base + ['rsa-key-name']):
-            if not config.exists(rsa_keys_base + ['rsa-key-name', rsa_name, 'rsa-key']):
-                continue
+def migrate(config: ConfigTree) -> None:
+    if config.exists(rsa_keys_base):
+        if not config.exists(pki_base + ['key-pair']):
+            config.set(pki_base + ['key-pair'])
+            config.set_tag(pki_base + ['key-pair'])
+
+        if config.exists(rsa_keys_base + ['local-key', 'file']):
+            local_file = config.return_value(rsa_keys_base + ['local-key', 'file'])
+            local_path = None
+            local_key = None
+
+            for path in LOCAL_KEY_PATHS:
+                full_path = os.path.join(path, local_file)
+                if os.path.exists(full_path):
+                    local_path = full_path
+                    break
+
+            if local_path:
+                with open(local_path, 'r') as f:
+                    local_key_data = f.read()
+                    local_key = load_private_key(local_key_data, wrap_tags=False)
+
+            if local_key:
+                local_key_pem = encode_private_key(local_key)
+                config.set(pki_base + ['key-pair', local_key_name, 'private', 'key'], value=wrapped_pem_to_config_value(local_key_pem))
+            else:
+                print('Failed to migrate local RSA key')
 
-            vyatta_key = config.return_value(rsa_keys_base + ['rsa-key-name', rsa_name, 'rsa-key'])
-            public_key = migrate_from_vyatta_key(vyatta_key)
+        if config.exists(rsa_keys_base + ['rsa-key-name']):
+            for rsa_name in config.list_nodes(rsa_keys_base + ['rsa-key-name']):
+                if not config.exists(rsa_keys_base + ['rsa-key-name', rsa_name, 'rsa-key']):
+                    continue
 
-            if public_key:
-                public_key_pem = encode_public_key(public_key)
-                config.set(pki_base + ['key-pair', rsa_name, 'public', 'key'], value=wrapped_pem_to_config_value(public_key_pem))
-            else:
-                print(f'Failed to migrate rsa-key "{rsa_name}"')
+                vyatta_key = config.return_value(rsa_keys_base + ['rsa-key-name', rsa_name, 'rsa-key'])
+                public_key = migrate_from_vyatta_key(vyatta_key)
 
-    config.delete(rsa_keys_base)
+                if public_key:
+                    public_key_pem = encode_public_key(public_key)
+                    config.set(pki_base + ['key-pair', rsa_name, 'public', 'key'], value=wrapped_pem_to_config_value(public_key_pem))
+                else:
+                    print(f'Failed to migrate rsa-key "{rsa_name}"')
 
-if config.exists(ipsec_site_base):
-    for peer in config.list_nodes(ipsec_site_base):
-        mode = config.return_value(ipsec_site_base + [peer, 'authentication', 'mode'])
+        config.delete(rsa_keys_base)
 
-        if mode != 'rsa':
-            continue
+    if config.exists(ipsec_site_base):
+        for peer in config.list_nodes(ipsec_site_base):
+            mode = config.return_value(ipsec_site_base + [peer, 'authentication', 'mode'])
 
-        config.set(ipsec_site_base + [peer, 'authentication', 'rsa', 'local-key'], value=local_key_name)
+            if mode != 'rsa':
+                continue
 
-        remote_key_name = config.return_value(ipsec_site_base + [peer, 'authentication', 'rsa-key-name'])
-        config.set(ipsec_site_base + [peer, 'authentication', 'rsa', 'remote-key'], value=remote_key_name)
-        config.delete(ipsec_site_base + [peer, 'authentication', 'rsa-key-name'])
+            config.set(ipsec_site_base + [peer, 'authentication', 'rsa', 'local-key'], value=local_key_name)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+            remote_key_name = config.return_value(ipsec_site_base + [peer, 'authentication', 'rsa-key-name'])
+            config.set(ipsec_site_base + [peer, 'authentication', 'rsa', 'remote-key'], value=remote_key_name)
+            config.delete(ipsec_site_base + [peer, 'authentication', 'rsa-key-name'])
diff --git a/src/migration-scripts/ipsec/8-to-9 b/src/migration-scripts/ipsec/8-to-9
old mode 100755
new mode 100644
index c08411f83..7f325139f
--- a/src/migration-scripts/ipsec/8-to-9
+++ b/src/migration-scripts/ipsec/8-to-9
@@ -1,48 +1,30 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-from sys import argv
-from sys import exit
+# T4288 : close-action is missing in swanctl.conf
 
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['vpn', 'ipsec', 'ike-group']
-config = ConfigTree(config_file)
 
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-else:
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
     for ike_group in config.list_nodes(base):
         base_closeaction = base + [ike_group, 'close-action']
         if config.exists(base_closeaction) and config.return_value(base_closeaction) == 'clear':
             config.set(base_closeaction, 'none', replace=True)
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
diff --git a/src/migration-scripts/ipsec/9-to-10 b/src/migration-scripts/ipsec/9-to-10
old mode 100755
new mode 100644
index bc10e1997..321a75973
--- a/src/migration-scripts/ipsec/9-to-10
+++ b/src/migration-scripts/ipsec/9-to-10
@@ -1,131 +1,114 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-import re
+# T4118: Change vpn ipsec syntax for IKE ESP and peer
+# T4879: IPsec migration script remote-id for peer name eq address
 
-from sys import argv
-from sys import exit
+import re
 
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['vpn', 'ipsec']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-# IKE changes, T4118:
-if config.exists(base + ['ike-group']):
-    for ike_group in config.list_nodes(base + ['ike-group']):
-        # replace 'ipsec ike-group <tag> mobike disable'
-        #      => 'ipsec ike-group <tag> disable-mobike'
-        mobike = base + ['ike-group', ike_group, 'mobike']
-        if config.exists(mobike):
-            if config.return_value(mobike) == 'disable':
-                config.set(base + ['ike-group', ike_group, 'disable-mobike'])
-            config.delete(mobike)
-
-        # replace 'ipsec ike-group <tag> ikev2-reauth yes'
-        #      => 'ipsec ike-group <tag> ikev2-reauth'
-        reauth = base + ['ike-group', ike_group, 'ikev2-reauth']
-        if config.exists(reauth):
-            if config.return_value(reauth) == 'yes':
-                config.delete(reauth)
-                config.set(reauth)
-            else:
-                config.delete(reauth)
-
-# ESP changes
-# replace 'ipsec esp-group <tag> compression enable'
-#      => 'ipsec esp-group <tag> compression'
-if config.exists(base + ['esp-group']):
-    for esp_group in config.list_nodes(base + ['esp-group']):
-        compression = base + ['esp-group', esp_group, 'compression']
-        if config.exists(compression):
-            if config.return_value(compression) == 'enable':
-                config.delete(compression)
-                config.set(compression)
-            else:
-                config.delete(compression)
-
-# PEER changes
-if config.exists(base + ['site-to-site', 'peer']):
-    for peer in config.list_nodes(base + ['site-to-site', 'peer']):
-        peer_base = base + ['site-to-site', 'peer', peer]
-
-        # replace: 'peer <tag> id x'
-        #       => 'peer <tag> local-id x'
-        if config.exists(peer_base + ['authentication', 'id']):
-            config.rename(peer_base + ['authentication', 'id'], 'local-id')
-
-        # For the peer '@foo' set remote-id 'foo' if remote-id is not defined
-        # For the peer '192.0.2.1' set remote-id '192.0.2.1' if remote-id is not defined
-        if not config.exists(peer_base + ['authentication', 'remote-id']):
-            tmp = peer.replace('@', '') if peer.startswith('@') else peer
-            config.set(peer_base + ['authentication', 'remote-id'], value=tmp)
-
-        # replace: 'peer <tag> force-encapsulation enable'
-        #       => 'peer <tag> force-udp-encapsulation'
-        force_enc = peer_base + ['force-encapsulation']
-        if config.exists(force_enc):
-            if config.return_value(force_enc) == 'enable':
-                config.delete(force_enc)
-                config.set(peer_base + ['force-udp-encapsulation'])
-            else:
-                config.delete(force_enc)
-
-        # add option: 'peer <tag> remote-address x.x.x.x'
-        remote_address = peer
-        if peer.startswith('@'):
-            remote_address = 'any'
-        config.set(peer_base + ['remote-address'], value=remote_address)
-        # Peer name it is swanctl connection name and shouldn't contain dots or colons
-        # rename peer:
-        #   peer 192.0.2.1   => peer peer_192-0-2-1
-        #   peer 2001:db8::2 => peer peer_2001-db8--2
-        #   peer @foo        => peer peer_foo
-        re_peer_name = re.sub(':|\.', '-', peer)
-        if re_peer_name.startswith('@'):
-            re_peer_name = re.sub('@', '', re_peer_name)
-        new_peer_name = f'peer_{re_peer_name}'
-
-        config.rename(peer_base, new_peer_name)
-
-# remote-access/road-warrior changes
-if config.exists(base + ['remote-access', 'connection']):
-    for connection in config.list_nodes(base + ['remote-access', 'connection']):
-        ra_base = base + ['remote-access', 'connection', connection]
-        # replace: 'remote-access connection <tag> authentication id x'
-        #       => 'remote-access connection <tag> authentication local-id x'
-        if config.exists(ra_base + ['authentication', 'id']):
-            config.rename(ra_base + ['authentication', 'id'], 'local-id')
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    # IKE changes, T4118:
+    if config.exists(base + ['ike-group']):
+        for ike_group in config.list_nodes(base + ['ike-group']):
+            # replace 'ipsec ike-group <tag> mobike disable'
+            #      => 'ipsec ike-group <tag> disable-mobike'
+            mobike = base + ['ike-group', ike_group, 'mobike']
+            if config.exists(mobike):
+                if config.return_value(mobike) == 'disable':
+                    config.set(base + ['ike-group', ike_group, 'disable-mobike'])
+                config.delete(mobike)
+
+            # replace 'ipsec ike-group <tag> ikev2-reauth yes'
+            #      => 'ipsec ike-group <tag> ikev2-reauth'
+            reauth = base + ['ike-group', ike_group, 'ikev2-reauth']
+            if config.exists(reauth):
+                if config.return_value(reauth) == 'yes':
+                    config.delete(reauth)
+                    config.set(reauth)
+                else:
+                    config.delete(reauth)
+
+    # ESP changes
+    # replace 'ipsec esp-group <tag> compression enable'
+    #      => 'ipsec esp-group <tag> compression'
+    if config.exists(base + ['esp-group']):
+        for esp_group in config.list_nodes(base + ['esp-group']):
+            compression = base + ['esp-group', esp_group, 'compression']
+            if config.exists(compression):
+                if config.return_value(compression) == 'enable':
+                    config.delete(compression)
+                    config.set(compression)
+                else:
+                    config.delete(compression)
+
+    # PEER changes
+    if config.exists(base + ['site-to-site', 'peer']):
+        for peer in config.list_nodes(base + ['site-to-site', 'peer']):
+            peer_base = base + ['site-to-site', 'peer', peer]
+
+            # replace: 'peer <tag> id x'
+            #       => 'peer <tag> local-id x'
+            if config.exists(peer_base + ['authentication', 'id']):
+                config.rename(peer_base + ['authentication', 'id'], 'local-id')
+
+            # For the peer '@foo' set remote-id 'foo' if remote-id is not defined
+            # For the peer '192.0.2.1' set remote-id '192.0.2.1' if remote-id is not defined
+            if not config.exists(peer_base + ['authentication', 'remote-id']):
+                tmp = peer.replace('@', '') if peer.startswith('@') else peer
+                config.set(peer_base + ['authentication', 'remote-id'], value=tmp)
+
+            # replace: 'peer <tag> force-encapsulation enable'
+            #       => 'peer <tag> force-udp-encapsulation'
+            force_enc = peer_base + ['force-encapsulation']
+            if config.exists(force_enc):
+                if config.return_value(force_enc) == 'enable':
+                    config.delete(force_enc)
+                    config.set(peer_base + ['force-udp-encapsulation'])
+                else:
+                    config.delete(force_enc)
+
+            # add option: 'peer <tag> remote-address x.x.x.x'
+            remote_address = peer
+            if peer.startswith('@'):
+                remote_address = 'any'
+            config.set(peer_base + ['remote-address'], value=remote_address)
+            # Peer name it is swanctl connection name and shouldn't contain dots or colons
+            # rename peer:
+            #   peer 192.0.2.1   => peer peer_192-0-2-1
+            #   peer 2001:db8::2 => peer peer_2001-db8--2
+            #   peer @foo        => peer peer_foo
+            re_peer_name = re.sub(':|\.', '-', peer)
+            if re_peer_name.startswith('@'):
+                re_peer_name = re.sub('@', '', re_peer_name)
+            new_peer_name = f'peer_{re_peer_name}'
+
+            config.rename(peer_base, new_peer_name)
+
+    # remote-access/road-warrior changes
+    if config.exists(base + ['remote-access', 'connection']):
+        for connection in config.list_nodes(base + ['remote-access', 'connection']):
+            ra_base = base + ['remote-access', 'connection', connection]
+            # replace: 'remote-access connection <tag> authentication id x'
+            #       => 'remote-access connection <tag> authentication local-id x'
+            if config.exists(ra_base + ['authentication', 'id']):
+                config.rename(ra_base + ['authentication', 'id'], 'local-id')
diff --git a/src/migration-scripts/isis/0-to-1 b/src/migration-scripts/isis/0-to-1
old mode 100755
new mode 100644
index 0149c0c1f..e24288558
--- a/src/migration-scripts/isis/0-to-1
+++ b/src/migration-scripts/isis/0-to-1
@@ -1,56 +1,36 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3417: migrate IS-IS tagNode to node as we can only have one IS-IS process
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['protocols', 'isis']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
 
-# We need a temporary copy of the config
-tmp_base = ['protocols', 'isis2']
-config.copy(base, tmp_base)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-# Now it's save to delete the old configuration
-config.delete(base)
+    # We need a temporary copy of the config
+    tmp_base = ['protocols', 'isis2']
+    config.copy(base, tmp_base)
 
-# Rename temporary copy to new final config (IS-IS domain key is static and no
-# longer required to be set via CLI)
-config.rename(tmp_base, 'isis')
+    # Now it's save to delete the old configuration
+    config.delete(base)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+    # Rename temporary copy to new final config (IS-IS domain key is static and no
+    # longer required to be set via CLI)
+    config.rename(tmp_base, 'isis')
diff --git a/src/migration-scripts/isis/1-to-2 b/src/migration-scripts/isis/1-to-2
old mode 100755
new mode 100644
index 9c110bf2a..0fc92a6de
--- a/src/migration-scripts/isis/1-to-2
+++ b/src/migration-scripts/isis/1-to-2
@@ -1,46 +1,27 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T4739 refactor, and remove "on" from segment routing from the configuration
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
-# Check if ISIS segment routing is configured. Then check if segment routing "on" exists, then delete the "on" as it is no longer needed. This is for global configuration.
-if config.exists(['protocols', 'isis']):
-    if config.exists(['protocols', 'isis', 'segment-routing']):
-        if config.exists(['protocols', 'isis', 'segment-routing', 'enable']):
-            config.delete(['protocols', 'isis', 'segment-routing', 'enable'])
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    # Check if ISIS segment routing is configured. Then check if segment
+    # routing "on" exists, then delete the "on" as it is no longer needed.
+    # This is for global configuration.
+    if config.exists(['protocols', 'isis']):
+        if config.exists(['protocols', 'isis', 'segment-routing']):
+            if config.exists(['protocols', 'isis', 'segment-routing', 'enable']):
+                config.delete(['protocols', 'isis', 'segment-routing', 'enable'])
diff --git a/src/migration-scripts/isis/2-to-3 b/src/migration-scripts/isis/2-to-3
old mode 100755
new mode 100644
index 78e3c1715..afb9f2340
--- a/src/migration-scripts/isis/2-to-3
+++ b/src/migration-scripts/isis/2-to-3
@@ -1,63 +1,43 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5150: Rework CLI definitions to apply route-maps between routing daemons
 #        and zebra/kernel
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 isis_base = ['protocols', 'isis']
-# Check if IS-IS is configured - if so, migrate the CLI node
-if config.exists(isis_base):
-    if config.exists(isis_base + ['route-map']):
-        tmp = config.return_value(isis_base + ['route-map'])
-
-        config.set(['system', 'ip', 'protocol', 'isis', 'route-map'], value=tmp)
-        config.set_tag(['system', 'ip', 'protocol'])
-        config.delete(isis_base + ['route-map'])
-
-# Check if vrf names are configured. Check if IS-IS is configured - if so,
-# migrate  the CLI node(s)
-if config.exists(['vrf', 'name']):
-    for vrf in config.list_nodes(['vrf', 'name']):
-        vrf_base = ['vrf', 'name', vrf]
-        if config.exists(vrf_base + ['protocols', 'isis', 'route-map']):
-            tmp = config.return_value(vrf_base + ['protocols', 'isis', 'route-map'])
-
-            config.set(vrf_base + ['ip', 'protocol', 'isis', 'route-map'], value=tmp)
-            config.set_tag(vrf_base + ['ip', 'protocol', 'isis'])
-            config.delete(vrf_base + ['protocols', 'isis', 'route-map'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    # Check if IS-IS is configured - if so, migrate the CLI node
+    if config.exists(isis_base):
+        if config.exists(isis_base + ['route-map']):
+            tmp = config.return_value(isis_base + ['route-map'])
+
+            config.set(['system', 'ip', 'protocol', 'isis', 'route-map'], value=tmp)
+            config.set_tag(['system', 'ip', 'protocol'])
+            config.delete(isis_base + ['route-map'])
+
+    # Check if vrf names are configured. Check if IS-IS is configured - if so,
+    # migrate  the CLI node(s)
+    if config.exists(['vrf', 'name']):
+        for vrf in config.list_nodes(['vrf', 'name']):
+            vrf_base = ['vrf', 'name', vrf]
+            if config.exists(vrf_base + ['protocols', 'isis', 'route-map']):
+                tmp = config.return_value(vrf_base + ['protocols', 'isis', 'route-map'])
+
+                config.set(vrf_base + ['ip', 'protocol', 'isis', 'route-map'], value=tmp)
+                config.set_tag(vrf_base + ['ip', 'protocol', 'isis'])
+                config.delete(vrf_base + ['protocols', 'isis', 'route-map'])
diff --git a/src/migration-scripts/l2tp/0-to-1 b/src/migration-scripts/l2tp/0-to-1
old mode 100755
new mode 100644
index 15d229822..f0cb6af96
--- a/src/migration-scripts/l2tp/0-to-1
+++ b/src/migration-scripts/l2tp/0-to-1
@@ -1,60 +1,56 @@
-#!/usr/bin/env python3
-
+# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
+
+# T987: Unclutter L2TP/IPSec RADIUS configuration nodes
 # Unclutter L2TP VPN configuiration - move radius-server top level tag
 # nodes to a regular node which now also configures the radius source address
 # used when querying a radius server
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
+cfg_base = ['vpn', 'l2tp', 'remote-access', 'authentication']
 
-config = ConfigTree(config_file)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(cfg_base):
+        # Nothing to do
+        return
 
-cfg_base = ['vpn', 'l2tp', 'remote-access', 'authentication']
-if not config.exists(cfg_base):
-    # Nothing to do
-    sys.exit(0)
-else:
     # Migrate "vpn l2tp authentication radius-source-address" to new
     # "vpn l2tp authentication radius source-address"
     if config.exists(cfg_base + ['radius-source-address']):
         address = config.return_value(cfg_base + ['radius-source-address'])
         # delete old configuration node
         config.delete(cfg_base + ['radius-source-address'])
         # write new configuration node
         config.set(cfg_base + ['radius', 'source-address'], value=address)
 
     # Migrate "vpn l2tp authentication radius-server" tag node to new
     # "vpn l2tp authentication radius server" tag node
     if config.exists(cfg_base + ['radius-server']):
         for server in config.list_nodes(cfg_base + ['radius-server']):
             base_server = cfg_base + ['radius-server', server]
             key = config.return_value(base_server + ['key'])
 
             # delete old configuration node
             config.delete(base_server)
             # write new configuration node
             config.set(cfg_base + ['radius', 'server', server, 'key'], value=key)
 
             # format as tag node
             config.set_tag(cfg_base + ['radius', 'server'])
 
     # delete top level tag node
     if config.exists(cfg_base + ['radius-server']):
         config.delete(cfg_base + ['radius-server'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/l2tp/1-to-2 b/src/migration-scripts/l2tp/1-to-2
old mode 100755
new mode 100644
index 2ffb91c53..468d564ac
--- a/src/migration-scripts/l2tp/1-to-2
+++ b/src/migration-scripts/l2tp/1-to-2
@@ -1,33 +1,28 @@
-#!/usr/bin/env python3
-
-# Delete depricated outside-nexthop address
-
-import sys
+# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
+
+# T1858: Delete deprecated outside-nexthop
 
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
+cfg_base = ['vpn', 'l2tp', 'remote-access']
 
-config = ConfigTree(config_file)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(cfg_base):
+        # Nothing to do
+        return
 
-cfg_base = ['vpn', 'l2tp', 'remote-access']
-if not config.exists(cfg_base):
-    # Nothing to do
-    sys.exit(0)
-else:
     if config.exists(cfg_base + ['outside-nexthop']):
         config.delete(cfg_base + ['outside-nexthop'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/l2tp/2-to-3 b/src/migration-scripts/l2tp/2-to-3
old mode 100755
new mode 100644
index 8527c2d4a..00fabb6b6
--- a/src/migration-scripts/l2tp/2-to-3
+++ b/src/migration-scripts/l2tp/2-to-3
@@ -1,107 +1,92 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-# - remove primary/secondary identifier from nameserver
-# - TODO: remove radius server req-limit
+# T2264: combine IPv4/IPv6 name-server CLI syntax
+# T2264: combine WINS CLI syntax
+# T2264: remove RADIUS req-limit node
+# T2264: migrate IPv6 prefix node to common CLI style
 
-from sys import argv, exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
+base = ['vpn', 'l2tp', 'remote-access']
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-base = ['vpn', 'l2tp', 'remote-access']
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-else:
     # Migrate IPv4 DNS servers
     dns_base = base + ['dns-servers']
     if config.exists(dns_base):
         for server in ['server-1', 'server-2']:
           if config.exists(dns_base + [server]):
             dns = config.return_value(dns_base + [server])
             config.set(base + ['name-server'], value=dns, replace=False)
 
         config.delete(dns_base)
 
     # Migrate IPv6 DNS servers
     dns_base = base + ['dnsv6-servers']
     if config.exists(dns_base):
         for server in config.return_values(dns_base):
             config.set(base + ['name-server'], value=server, replace=False)
 
         config.delete(dns_base)
 
     # Migrate IPv4 WINS servers
     wins_base = base + ['wins-servers']
     if config.exists(wins_base):
         for server in ['server-1', 'server-2']:
           if config.exists(wins_base + [server]):
             wins = config.return_value(wins_base + [server])
             config.set(base + ['wins-server'], value=wins, replace=False)
 
         config.delete(wins_base)
 
 
     # Remove RADIUS server req-limit node
     radius_base = base + ['authentication', 'radius']
     if config.exists(radius_base):
         for server in config.list_nodes(radius_base + ['server']):
             if config.exists(radius_base + ['server', server, 'req-limit']):
                 config.delete(radius_base + ['server', server, 'req-limit'])
 
     # Migrate IPv6 prefixes
     ipv6_base = base + ['client-ipv6-pool']
     if config.exists(ipv6_base + ['prefix']):
         prefix_old = config.return_values(ipv6_base + ['prefix'])
         # delete old prefix CLI nodes
         config.delete(ipv6_base + ['prefix'])
         # create ned prefix tag node
         config.set(ipv6_base + ['prefix'])
         config.set_tag(ipv6_base + ['prefix'])
 
         for p in prefix_old:
             prefix = p.split(',')[0]
             mask = p.split(',')[1]
             config.set(ipv6_base + ['prefix', prefix, 'mask'], value=mask)
 
     if config.exists(ipv6_base + ['delegate-prefix']):
         prefix_old = config.return_values(ipv6_base + ['delegate-prefix'])
         # delete old delegate prefix CLI nodes
         config.delete(ipv6_base + ['delegate-prefix'])
         # create ned delegation tag node
         config.set(ipv6_base + ['delegate'])
         config.set_tag(ipv6_base + ['delegate'])
 
         for p in prefix_old:
             prefix = p.split(',')[0]
             mask = p.split(',')[1]
             config.set(ipv6_base + ['delegate', prefix, 'delegate-prefix'], value=mask)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/l2tp/3-to-4 b/src/migration-scripts/l2tp/3-to-4
old mode 100755
new mode 100644
index 14b86ff04..01c3fa844
--- a/src/migration-scripts/l2tp/3-to-4
+++ b/src/migration-scripts/l2tp/3-to-4
@@ -1,168 +1,148 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-# - remove primary/secondary identifier from nameserver
-# - TODO: remove radius server req-limit
+# T2816: T3642: Move IPSec/L2TP code into vpn_ipsec.py and update to use PKI.
 
 import os
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 from vyos.pki import load_certificate
 from vyos.pki import load_private_key
 from vyos.pki import encode_certificate
 from vyos.pki import encode_private_key
 from vyos.utils.process import run
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['vpn', 'l2tp', 'remote-access', 'ipsec-settings']
 pki_base = ['pki']
 
-if not config.exists(base):
-    exit(0)
-
 AUTH_DIR = '/config/auth'
 
 def wrapped_pem_to_config_value(pem):
     return "".join(pem.strip().split("\n")[1:-1])
 
-if not config.exists(base + ['authentication', 'x509']):
-    exit(0)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
+
+    if not config.exists(base + ['authentication', 'x509']):
+        return
+
+    x509_base = base + ['authentication', 'x509']
+    pki_name = 'l2tp_remote_access'
+
+    if not config.exists(pki_base + ['ca']):
+        config.set(pki_base + ['ca'])
+        config.set_tag(pki_base + ['ca'])
+
+    if not config.exists(pki_base + ['certificate']):
+        config.set(pki_base + ['certificate'])
+        config.set_tag(pki_base + ['certificate'])
+
+    if config.exists(x509_base + ['ca-cert-file']):
+        cert_file = config.return_value(x509_base + ['ca-cert-file'])
+        cert_path = os.path.join(AUTH_DIR, cert_file)
+        cert = None
+
+        if os.path.isfile(cert_path):
+            if not os.access(cert_path, os.R_OK):
+                run(f'sudo chmod 644 {cert_path}')
+
+            with open(cert_path, 'r') as f:
+                cert_data = f.read()
+                cert = load_certificate(cert_data, wrap_tags=False)
+
+        if cert:
+            cert_pem = encode_certificate(cert)
+            config.set(pki_base + ['ca', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+            config.set(x509_base + ['ca-certificate'], value=pki_name)
+        else:
+            print(f'Failed to migrate CA certificate on l2tp remote-access config')
+
+        config.delete(x509_base + ['ca-cert-file'])
+
+    if config.exists(x509_base + ['crl-file']):
+        crl_file = config.return_value(x509_base + ['crl-file'])
+        crl_path = os.path.join(AUTH_DIR, crl_file)
+        crl = None
+
+        if os.path.isfile(crl_path):
+            if not os.access(crl_path, os.R_OK):
+                run(f'sudo chmod 644 {crl_path}')
+
+            with open(crl_path, 'r') as f:
+                crl_data = f.read()
+                crl = load_certificate(crl_data, wrap_tags=False)
+
+        if crl:
+            crl_pem = encode_certificate(crl)
+            config.set(pki_base + ['ca', pki_name, 'crl'], value=wrapped_pem_to_config_value(crl_pem))
+        else:
+            print(f'Failed to migrate CRL on l2tp remote-access config')
+
+        config.delete(x509_base + ['crl-file'])
+
+    if config.exists(x509_base + ['server-cert-file']):
+        cert_file = config.return_value(x509_base + ['server-cert-file'])
+        cert_path = os.path.join(AUTH_DIR, cert_file)
+        cert = None
+
+        if os.path.isfile(cert_path):
+            if not os.access(cert_path, os.R_OK):
+                run(f'sudo chmod 644 {cert_path}')
+
+            with open(cert_path, 'r') as f:
+                cert_data = f.read()
+                cert = load_certificate(cert_data, wrap_tags=False)
+
+        if cert:
+            cert_pem = encode_certificate(cert)
+            config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+            config.set(x509_base + ['certificate'], value=pki_name)
+        else:
+            print(f'Failed to migrate certificate on l2tp remote-access config')
+
+        config.delete(x509_base + ['server-cert-file'])
+
+    if config.exists(x509_base + ['server-key-file']):
+        key_file = config.return_value(x509_base + ['server-key-file'])
+        key_passphrase = None
 
-x509_base = base + ['authentication', 'x509']
-pki_name = 'l2tp_remote_access'
+        if config.exists(x509_base + ['server-key-password']):
+            key_passphrase = config.return_value(x509_base + ['server-key-password'])
 
-if not config.exists(pki_base + ['ca']):
-    config.set(pki_base + ['ca'])
-    config.set_tag(pki_base + ['ca'])
+        key_path = os.path.join(AUTH_DIR, key_file)
+        key = None
 
-if not config.exists(pki_base + ['certificate']):
-    config.set(pki_base + ['certificate'])
-    config.set_tag(pki_base + ['certificate'])
+        if os.path.isfile(key_path):
+            if not os.access(key_path, os.R_OK):
+                run(f'sudo chmod 644 {key_path}')
 
-if config.exists(x509_base + ['ca-cert-file']):
-    cert_file = config.return_value(x509_base + ['ca-cert-file'])
-    cert_path = os.path.join(AUTH_DIR, cert_file)
-    cert = None
-
-    if os.path.isfile(cert_path):
-        if not os.access(cert_path, os.R_OK):
-            run(f'sudo chmod 644 {cert_path}')
-
-        with open(cert_path, 'r') as f:
-            cert_data = f.read()
-            cert = load_certificate(cert_data, wrap_tags=False)
-
-    if cert:
-        cert_pem = encode_certificate(cert)
-        config.set(pki_base + ['ca', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
-        config.set(x509_base + ['ca-certificate'], value=pki_name)
-    else:
-        print(f'Failed to migrate CA certificate on l2tp remote-access config')
-
-    config.delete(x509_base + ['ca-cert-file'])
-
-if config.exists(x509_base + ['crl-file']):
-    crl_file = config.return_value(x509_base + ['crl-file'])
-    crl_path = os.path.join(AUTH_DIR, crl_file)
-    crl = None
-
-    if os.path.isfile(crl_path):
-        if not os.access(crl_path, os.R_OK):
-            run(f'sudo chmod 644 {crl_path}')
-
-        with open(crl_path, 'r') as f:
-            crl_data = f.read()
-            crl = load_certificate(crl_data, wrap_tags=False)
-
-    if crl:
-        crl_pem = encode_certificate(crl)
-        config.set(pki_base + ['ca', pki_name, 'crl'], value=wrapped_pem_to_config_value(crl_pem))
-    else:
-        print(f'Failed to migrate CRL on l2tp remote-access config')
-
-    config.delete(x509_base + ['crl-file'])
-
-if config.exists(x509_base + ['server-cert-file']):
-    cert_file = config.return_value(x509_base + ['server-cert-file'])
-    cert_path = os.path.join(AUTH_DIR, cert_file)
-    cert = None
-
-    if os.path.isfile(cert_path):
-        if not os.access(cert_path, os.R_OK):
-            run(f'sudo chmod 644 {cert_path}')
-
-        with open(cert_path, 'r') as f:
-            cert_data = f.read()
-            cert = load_certificate(cert_data, wrap_tags=False)
-
-    if cert:
-        cert_pem = encode_certificate(cert)
-        config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
-        config.set(x509_base + ['certificate'], value=pki_name)
-    else:
-        print(f'Failed to migrate certificate on l2tp remote-access config')
-
-    config.delete(x509_base + ['server-cert-file'])
-
-if config.exists(x509_base + ['server-key-file']):
-    key_file = config.return_value(x509_base + ['server-key-file'])
-    key_passphrase = None
-
-    if config.exists(x509_base + ['server-key-password']):
-        key_passphrase = config.return_value(x509_base + ['server-key-password'])
-
-    key_path = os.path.join(AUTH_DIR, key_file)
-    key = None
-
-    if os.path.isfile(key_path):
-        if not os.access(key_path, os.R_OK):
-            run(f'sudo chmod 644 {key_path}')
-
-        with open(key_path, 'r') as f:
-            key_data = f.read()
-            key = load_private_key(key_data, passphrase=key_passphrase, wrap_tags=False)
-
-    if key:
-        key_pem = encode_private_key(key, passphrase=key_passphrase)
-        config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
-
-        if key_passphrase:
-            config.set(pki_base + ['certificate', pki_name, 'private', 'password-protected'])
-            config.set(x509_base + ['private-key-passphrase'], value=key_passphrase)
-    else:
-        print(f'Failed to migrate private key on l2tp remote-access config')
-
-    config.delete(x509_base + ['server-key-file'])
-    if config.exists(x509_base + ['server-key-password']):
-        config.delete(x509_base + ['server-key-password'])
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+            with open(key_path, 'r') as f:
+                key_data = f.read()
+                key = load_private_key(key_data, passphrase=key_passphrase, wrap_tags=False)
+
+        if key:
+            key_pem = encode_private_key(key, passphrase=key_passphrase)
+            config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
+
+            if key_passphrase:
+                config.set(pki_base + ['certificate', pki_name, 'private', 'password-protected'])
+                config.set(x509_base + ['private-key-passphrase'], value=key_passphrase)
+        else:
+            print(f'Failed to migrate private key on l2tp remote-access config')
+
+        config.delete(x509_base + ['server-key-file'])
+        if config.exists(x509_base + ['server-key-password']):
+            config.delete(x509_base + ['server-key-password'])
diff --git a/src/migration-scripts/l2tp/4-to-5 b/src/migration-scripts/l2tp/4-to-5
old mode 100755
new mode 100644
index b7f4d2677..56d451b8d
--- a/src/migration-scripts/l2tp/4-to-5
+++ b/src/migration-scripts/l2tp/4-to-5
@@ -1,85 +1,68 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - move all pool to named pools
 #       'start-stop' migrate to namedpool 'default-range-pool'
 #       'subnet' migrate to namedpool 'default-subnet-pool'
 #       'default-subnet-pool' is the next pool for 'default-range-pool'
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 from vyos.base import Warning
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['vpn', 'l2tp', 'remote-access']
 pool_base = base + ['client-ip-pool']
-if not config.exists(base):
-    exit(0)
 
-if not config.exists(pool_base):
-    exit(0)
-default_pool = ''
-range_pool_name = 'default-range-pool'
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
 
-if config.exists(pool_base + ['start']) and config.exists(pool_base + ['stop']):
-    def is_legalrange(ip1: str, ip2: str, mask: str):
-        from ipaddress import IPv4Interface
-        interface1 = IPv4Interface(f'{ip1}/{mask}')
+    if not config.exists(pool_base):
+        return
 
-        interface2 = IPv4Interface(f'{ip2}/{mask}')
-        return interface1.network.network_address == interface2.network.network_address and interface2.ip > interface1.ip
+    default_pool = ''
+    range_pool_name = 'default-range-pool'
 
-    start_ip = config.return_value(pool_base + ['start'])
-    stop_ip = config.return_value(pool_base + ['stop'])
-    if is_legalrange(start_ip, stop_ip,'24'):
-        ip_range = f'{start_ip}-{stop_ip}'
-        config.set(pool_base + [range_pool_name, 'range'], value=ip_range, replace=False)
-        default_pool = range_pool_name
-    else:
-        Warning(
-            f'L2TP client-ip-pool range start-ip:{start_ip} and stop-ip:{stop_ip} can not be migrated.')
+    if config.exists(pool_base + ['start']) and config.exists(pool_base + ['stop']):
+        def is_legalrange(ip1: str, ip2: str, mask: str):
+            from ipaddress import IPv4Interface
+            interface1 = IPv4Interface(f'{ip1}/{mask}')
+
+            interface2 = IPv4Interface(f'{ip2}/{mask}')
+            return interface1.network.network_address == interface2.network.network_address and interface2.ip > interface1.ip
 
-    config.delete(pool_base + ['start'])
-    config.delete(pool_base + ['stop'])
+        start_ip = config.return_value(pool_base + ['start'])
+        stop_ip = config.return_value(pool_base + ['stop'])
+        if is_legalrange(start_ip, stop_ip,'24'):
+            ip_range = f'{start_ip}-{stop_ip}'
+            config.set(pool_base + [range_pool_name, 'range'], value=ip_range, replace=False)
+            default_pool = range_pool_name
+        else:
+            Warning(
+                f'L2TP client-ip-pool range start-ip:{start_ip} and stop-ip:{stop_ip} can not be migrated.')
 
-if config.exists(pool_base + ['subnet']):
-    for subnet in config.return_values(pool_base + ['subnet']):
-        config.set(pool_base + [range_pool_name, 'range'], value=subnet, replace=False)
+        config.delete(pool_base + ['start'])
+        config.delete(pool_base + ['stop'])
 
-    config.delete(pool_base + ['subnet'])
-    default_pool = range_pool_name
+    if config.exists(pool_base + ['subnet']):
+        for subnet in config.return_values(pool_base + ['subnet']):
+            config.set(pool_base + [range_pool_name, 'range'], value=subnet, replace=False)
 
-if default_pool:
-    config.set(base + ['default-pool'], value=default_pool)
-# format as tag node
-config.set_tag(pool_base)
+        config.delete(pool_base + ['subnet'])
+        default_pool = range_pool_name
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    if default_pool:
+        config.set(base + ['default-pool'], value=default_pool)
+    # format as tag node
+    config.set_tag(pool_base)
diff --git a/src/migration-scripts/l2tp/5-to-6 b/src/migration-scripts/l2tp/5-to-6
old mode 100755
new mode 100644
index ac40b89c8..cc9f948a6
--- a/src/migration-scripts/l2tp/5-to-6
+++ b/src/migration-scripts/l2tp/5-to-6
@@ -1,106 +1,88 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['vpn', 'l2tp', 'remote-access']
-if not config.exists(base):
-    exit(0)
-
-#migrate idle to ppp option lcp-echo-timeout
-idle_path = base + ['idle']
-if config.exists(idle_path):
-    config.set(base + ['ppp-options', 'lcp-echo-timeout'],
-               value=config.return_value(idle_path))
-    config.delete(idle_path)
-
-#migrate mppe from authentication to ppp-otion
-mppe_path = base + ['authentication', 'mppe']
-if config.exists(mppe_path):
-    config.set(base + ['ppp-options', 'mppe'],
-               value=config.return_value(mppe_path))
-    config.delete(mppe_path)
-
-#migrate require to protocol
-require_path = base + ['authentication', 'require']
-if config.exists(require_path):
-    protocols = list(config.return_values(require_path))
-    for protocol in protocols:
-        config.set(base + ['authentication', 'protocols'], value=protocol,
-                   replace=False)
-    config.delete(require_path)
-else:
-    config.set(base + ['authentication', 'protocols'], value='mschap-v2')
-
-#migrate default gateway if not exist
-if not config.exists(base + ['gateway-address']):
-    config.set(base + ['gateway-address'], value='10.255.255.0')
-
-#migrate authentication radius timeout
-rad_timeout_path = base + ['authentication', 'radius', 'timeout']
-if config.exists(rad_timeout_path):
-    if int(config.return_value(rad_timeout_path)) > 60:
-        config.set(rad_timeout_path, value=60)
-
-#migrate authentication radius acct timeout
-rad_acct_timeout_path = base + ['authentication', 'radius', 'acct-timeout']
-if config.exists(rad_acct_timeout_path):
-    if int(config.return_value(rad_acct_timeout_path)) > 60:
-        config.set(rad_acct_timeout_path,value=60)
-
-#migrate authentication radius max-try
-rad_max_try_path = base + ['authentication', 'radius', 'max-try']
-if config.exists(rad_max_try_path):
-    if int(config.return_value(rad_max_try_path)) > 20:
-        config.set(rad_max_try_path, value=20)
-
-#migrate dae-server to dynamic-author
-dae_path_old = base + ['authentication', 'radius', 'dae-server']
-dae_path_new = base + ['authentication', 'radius', 'dynamic-author']
-
-if config.exists(dae_path_old + ['ip-address']):
-    config.set(dae_path_new + ['server'],
-               value=config.return_value(dae_path_old + ['ip-address']))
-
-if config.exists(dae_path_old + ['port']):
-    config.set(dae_path_new + ['port'],
-               value=config.return_value(dae_path_old + ['port']))
-
-if config.exists(dae_path_old + ['secret']):
-    config.set(dae_path_new + ['key'],
-               value=config.return_value(dae_path_old + ['secret']))
-
-if config.exists(dae_path_old):
-    config.delete(dae_path_old)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
+
+    #migrate idle to ppp option lcp-echo-timeout
+    idle_path = base + ['idle']
+    if config.exists(idle_path):
+        config.set(base + ['ppp-options', 'lcp-echo-timeout'],
+                   value=config.return_value(idle_path))
+        config.delete(idle_path)
+
+    #migrate mppe from authentication to ppp-otion
+    mppe_path = base + ['authentication', 'mppe']
+    if config.exists(mppe_path):
+        config.set(base + ['ppp-options', 'mppe'],
+                   value=config.return_value(mppe_path))
+        config.delete(mppe_path)
+
+    #migrate require to protocol
+    require_path = base + ['authentication', 'require']
+    if config.exists(require_path):
+        protocols = list(config.return_values(require_path))
+        for protocol in protocols:
+            config.set(base + ['authentication', 'protocols'], value=protocol,
+                       replace=False)
+        config.delete(require_path)
+    else:
+        config.set(base + ['authentication', 'protocols'], value='mschap-v2')
+
+    #migrate default gateway if not exist
+    if not config.exists(base + ['gateway-address']):
+        config.set(base + ['gateway-address'], value='10.255.255.0')
+
+    #migrate authentication radius timeout
+    rad_timeout_path = base + ['authentication', 'radius', 'timeout']
+    if config.exists(rad_timeout_path):
+        if int(config.return_value(rad_timeout_path)) > 60:
+            config.set(rad_timeout_path, value=60)
+
+    #migrate authentication radius acct timeout
+    rad_acct_timeout_path = base + ['authentication', 'radius', 'acct-timeout']
+    if config.exists(rad_acct_timeout_path):
+        if int(config.return_value(rad_acct_timeout_path)) > 60:
+            config.set(rad_acct_timeout_path,value=60)
+
+    #migrate authentication radius max-try
+    rad_max_try_path = base + ['authentication', 'radius', 'max-try']
+    if config.exists(rad_max_try_path):
+        if int(config.return_value(rad_max_try_path)) > 20:
+            config.set(rad_max_try_path, value=20)
+
+    #migrate dae-server to dynamic-author
+    dae_path_old = base + ['authentication', 'radius', 'dae-server']
+    dae_path_new = base + ['authentication', 'radius', 'dynamic-author']
+
+    if config.exists(dae_path_old + ['ip-address']):
+        config.set(dae_path_new + ['server'],
+                   value=config.return_value(dae_path_old + ['ip-address']))
+
+    if config.exists(dae_path_old + ['port']):
+        config.set(dae_path_new + ['port'],
+                   value=config.return_value(dae_path_old + ['port']))
+
+    if config.exists(dae_path_old + ['secret']):
+        config.set(dae_path_new + ['key'],
+                   value=config.return_value(dae_path_old + ['secret']))
+
+    if config.exists(dae_path_old):
+        config.delete(dae_path_old)
diff --git a/src/migration-scripts/l2tp/6-to-7 b/src/migration-scripts/l2tp/6-to-7
old mode 100755
new mode 100644
index 1c536585c..4dba5974e
--- a/src/migration-scripts/l2tp/6-to-7
+++ b/src/migration-scripts/l2tp/6-to-7
@@ -1,57 +1,39 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Migrating to named ipv6 pools
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['vpn', 'l2tp', 'remote-access']
 pool_base = base + ['client-ipv6-pool']
-if not config.exists(base):
-    exit(0)
 
-if not config.exists(pool_base):
-    exit(0)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
 
-ipv6_pool_name = 'ipv6-pool'
-config.copy(pool_base, pool_base + [ipv6_pool_name])
+    if not config.exists(pool_base):
+        return
 
-if config.exists(pool_base + ['prefix']):
-    config.delete(pool_base + ['prefix'])
-    config.set(base + ['default-ipv6-pool'], value=ipv6_pool_name)
-if config.exists(pool_base + ['delegate']):
-    config.delete(pool_base + ['delegate'])
-# format as tag node
-config.set_tag(pool_base)
+    ipv6_pool_name = 'ipv6-pool'
+    config.copy(pool_base, pool_base + [ipv6_pool_name])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    if config.exists(pool_base + ['prefix']):
+        config.delete(pool_base + ['prefix'])
+        config.set(base + ['default-ipv6-pool'], value=ipv6_pool_name)
+    if config.exists(pool_base + ['delegate']):
+        config.delete(pool_base + ['delegate'])
+    # format as tag node
+    config.set_tag(pool_base)
diff --git a/src/migration-scripts/l2tp/7-to-8 b/src/migration-scripts/l2tp/7-to-8
old mode 100755
new mode 100644
index e429ed057..527906fc8
--- a/src/migration-scripts/l2tp/7-to-8
+++ b/src/migration-scripts/l2tp/7-to-8
@@ -1,65 +1,47 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Migrate from 'ccp-disable' to 'ppp-options.disable-ccp'
 # Migration ipv6 options
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['vpn', 'l2tp', 'remote-access']
-if not config.exists(base):
-    exit(0)
-
-#CCP migration
-if config.exists(base + ['ccp-disable']):
-    config.delete(base + ['ccp-disable'])
-    config.set(base + ['ppp-options', 'disable-ccp'])
-
-#IPV6 options migrations
-if config.exists(base + ['ppp-options','ipv6-peer-intf-id']):
-    intf_peer_id = config.return_value(base + ['ppp-options','ipv6-peer-intf-id'])
-    if intf_peer_id == 'ipv4':
-        intf_peer_id = 'ipv4-addr'
-    config.set(base + ['ppp-options','ipv6-peer-interface-id'], value=intf_peer_id, replace=True)
-    config.delete(base + ['ppp-options','ipv6-peer-intf-id'])
-
-if config.exists(base + ['ppp-options','ipv6-intf-id']):
-    intf_id = config.return_value(base + ['ppp-options','ipv6-intf-id'])
-    config.set(base + ['ppp-options','ipv6-interface-id'], value=intf_id, replace=True)
-    config.delete(base + ['ppp-options','ipv6-intf-id'])
-
-if config.exists(base + ['ppp-options','ipv6-accept-peer-intf-id']):
-    config.set(base + ['ppp-options','ipv6-accept-peer-interface-id'])
-    config.delete(base + ['ppp-options','ipv6-accept-peer-intf-id'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
+
+    #CCP migration
+    if config.exists(base + ['ccp-disable']):
+        config.delete(base + ['ccp-disable'])
+        config.set(base + ['ppp-options', 'disable-ccp'])
+
+    #IPV6 options migrations
+    if config.exists(base + ['ppp-options','ipv6-peer-intf-id']):
+        intf_peer_id = config.return_value(base + ['ppp-options','ipv6-peer-intf-id'])
+        if intf_peer_id == 'ipv4':
+            intf_peer_id = 'ipv4-addr'
+        config.set(base + ['ppp-options','ipv6-peer-interface-id'], value=intf_peer_id, replace=True)
+        config.delete(base + ['ppp-options','ipv6-peer-intf-id'])
+
+    if config.exists(base + ['ppp-options','ipv6-intf-id']):
+        intf_id = config.return_value(base + ['ppp-options','ipv6-intf-id'])
+        config.set(base + ['ppp-options','ipv6-interface-id'], value=intf_id, replace=True)
+        config.delete(base + ['ppp-options','ipv6-intf-id'])
+
+    if config.exists(base + ['ppp-options','ipv6-accept-peer-intf-id']):
+        config.set(base + ['ppp-options','ipv6-accept-peer-interface-id'])
+        config.delete(base + ['ppp-options','ipv6-accept-peer-intf-id'])
diff --git a/src/migration-scripts/l2tp/8-to-9 b/src/migration-scripts/l2tp/8-to-9
old mode 100755
new mode 100644
index 672180e25..e6b689e80
--- a/src/migration-scripts/l2tp/8-to-9
+++ b/src/migration-scripts/l2tp/8-to-9
@@ -1,46 +1,28 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Deleted 'dhcp-interface' from l2tp
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['vpn', 'l2tp', 'remote-access']
-if not config.exists(base):
-    exit(0)
 
-# deleting unused dhcp-interface
-if config.exists(base + ['dhcp-interface']):
-    config.delete(base + ['dhcp-interface'])
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    # deleting unused dhcp-interface
+    if config.exists(base + ['dhcp-interface']):
+        config.delete(base + ['dhcp-interface'])
diff --git a/src/migration-scripts/lldp/0-to-1 b/src/migration-scripts/lldp/0-to-1
old mode 100755
new mode 100644
index a99356062..c16e7e84b
--- a/src/migration-scripts/lldp/0-to-1
+++ b/src/migration-scripts/lldp/0-to-1
@@ -1,49 +1,31 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Delete "set service lldp interface <interface> location civic-based" option
 # as it was broken most of the time anyways
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
+base = ['service', 'lldp', 'interface']
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-base = ['service', 'lldp', 'interface']
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
-else:
     # Delete nodes with abandoned CLI syntax
     for interface in config.list_nodes(base):
         if config.exists(base + [interface, 'location', 'civic-based']):
             config.delete(base + [interface, 'location', 'civic-based'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/lldp/1-to-2 b/src/migration-scripts/lldp/1-to-2
old mode 100755
new mode 100644
index 35efb25db..7f233a725
--- a/src/migration-scripts/lldp/1-to-2
+++ b/src/migration-scripts/lldp/1-to-2
@@ -1,48 +1,30 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5855: migrate "set service lldp snmp enable" -> `set service lldp snmp"
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['service', 'lldp']
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
 
-if config.exists(base + ['snmp']):
-    enabled = config.exists(base + ['snmp', 'enable'])
-    config.delete(base + ['snmp'])
-    if enabled: config.set(base + ['snmp'])
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+    if config.exists(base + ['snmp']):
+        enabled = config.exists(base + ['snmp', 'enable'])
+        config.delete(base + ['snmp'])
+        if enabled: config.set(base + ['snmp'])
diff --git a/src/migration-scripts/monitoring/0-to-1 b/src/migration-scripts/monitoring/0-to-1
old mode 100755
new mode 100644
index 384d22f8c..92f824325
--- a/src/migration-scripts/monitoring/0-to-1
+++ b/src/migration-scripts/monitoring/0-to-1
@@ -1,71 +1,66 @@
 #!/usr/bin/env python3
 #
 # Copyright (C) 2022 VyOS maintainers and contributors
 #
 # This program is free software; you can redistribute it and/or modify
 # it under the terms of the GNU General Public License version 2 or later as
 # published by the Free Software Foundation.
 #
 # This program 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 for more details.
 #
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-# T3417: migrate IS-IS tagNode to node as we can only have one IS-IS process
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-from sys import argv
-from sys import exit
+# T3417: migrate IS-IS tagNode to node as we can only have one IS-IS process
 
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['service', 'monitoring', 'telegraf']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-if config.exists(base + ['authentication', 'organization']):
-    tmp = config.return_value(base + ['authentication', 'organization'])
-    config.delete(base + ['authentication', 'organization'])
-    config.set(base + ['influxdb', 'authentication', 'organization'], value=tmp)
 
-if config.exists(base + ['authentication', 'token']):
-    tmp = config.return_value(base + ['authentication', 'token'])
-    config.delete(base + ['authentication', 'token'])
-    config.set(base + ['influxdb', 'authentication', 'token'], value=tmp)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-if config.exists(base + ['bucket']):
-    tmp = config.return_value(base + ['bucket'])
-    config.delete(base + ['bucket'])
-    config.set(base + ['influxdb', 'bucket'], value=tmp)
+    if config.exists(base + ['authentication', 'organization']):
+        tmp = config.return_value(base + ['authentication', 'organization'])
+        config.delete(base + ['authentication', 'organization'])
+        config.set(base + ['influxdb', 'authentication', 'organization'], value=tmp)
 
-if config.exists(base + ['port']):
-    tmp = config.return_value(base + ['port'])
-    config.delete(base + ['port'])
-    config.set(base + ['influxdb', 'port'], value=tmp)
+    if config.exists(base + ['authentication', 'token']):
+        tmp = config.return_value(base + ['authentication', 'token'])
+        config.delete(base + ['authentication', 'token'])
+        config.set(base + ['influxdb', 'authentication', 'token'], value=tmp)
 
-if config.exists(base + ['url']):
-    tmp = config.return_value(base + ['url'])
-    config.delete(base + ['url'])
-    config.set(base + ['influxdb', 'url'], value=tmp)
+    if config.exists(base + ['bucket']):
+        tmp = config.return_value(base + ['bucket'])
+        config.delete(base + ['bucket'])
+        config.set(base + ['influxdb', 'bucket'], value=tmp)
 
+    if config.exists(base + ['port']):
+        tmp = config.return_value(base + ['port'])
+        config.delete(base + ['port'])
+        config.set(base + ['influxdb', 'port'], value=tmp)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+    if config.exists(base + ['url']):
+        tmp = config.return_value(base + ['url'])
+        config.delete(base + ['url'])
+        config.set(base + ['influxdb', 'url'], value=tmp)
diff --git a/src/migration-scripts/nat/4-to-5 b/src/migration-scripts/nat/4-to-5
old mode 100755
new mode 100644
index ce215d455..e1919da50
--- a/src/migration-scripts/nat/4-to-5
+++ b/src/migration-scripts/nat/4-to-5
@@ -1,64 +1,45 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Drop the enable/disable from the nat "log" node. If log node is specified
 # it is "enabled"
 
-from sys import argv,exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(['nat']):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-
-if not config.exists(['nat']):
-    # Nothing to do
-    exit(0)
-else:
     for direction in ['source', 'destination']:
         # If a node doesn't exist, we obviously have nothing to do.
         if not config.exists(['nat', direction]):
             continue
 
         # However, we also need to handle the case when a 'source' or 'destination' sub-node does exist,
         # but there are no rules under it.
         if not config.list_nodes(['nat', direction]):
             continue
 
         for rule in config.list_nodes(['nat', direction, 'rule']):
             base = ['nat', direction, 'rule', rule]
 
             # Check if the log node exists and if log is enabled,
             # migrate it to the new valueless 'log' node
             if config.exists(base + ['log']):
                 tmp = config.return_value(base + ['log'])
                 config.delete(base + ['log'])
                 if tmp == 'enable':
                     config.set(base + ['log'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/nat/5-to-6 b/src/migration-scripts/nat/5-to-6
old mode 100755
new mode 100644
index cfe98ddcf..a583d4eb6
--- a/src/migration-scripts/nat/5-to-6
+++ b/src/migration-scripts/nat/5-to-6
@@ -1,101 +1,82 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5643: move from 'set nat [source|destination] rule X [inbound-interface|outbound interface] <iface>'
 # to
 # 'set nat [source|destination] rule X [inbound-interface|outbound interface] interface-name <iface>'
 
 # T6100: Migration from 1.3.X to 1.4
 # Change IP/netmask to Network/netmask in
 #   'set nat [source|destination] rule X [source| destination| translation] address <IP/Netmask| !IP/Netmask>'
 
 import ipaddress
-from sys import argv,exit
+
 from vyos.configtree import ConfigTree
 
 
 def _func_T5643(conf, base_path):
     for iface in ['inbound-interface', 'outbound-interface']:
         if conf.exists(base_path + [iface]):
             tmp = conf.return_value(base_path + [iface])
             if tmp:
                 conf.delete(base_path + [iface])
                 conf.set(base_path + [iface, 'interface-name'], value=tmp)
     return
 
 
 def _func_T6100(conf, base_path):
     for addr_type in ['source', 'destination', 'translation']:
         base_addr_type = base_path + [addr_type]
         if not conf.exists(base_addr_type) or not conf.exists(
                 base_addr_type + ['address']):
             continue
 
         address = conf.return_value(base_addr_type + ['address'])
 
         if not address or '/' not in address:
             continue
 
         negative = ''
         network = address
         if '!' in address:
             negative = '!'
             network = str(address.split(negative)[1])
 
         network_ip = ipaddress.ip_network(network, strict=False)
         if str(network_ip) != network:
             network = f'{negative}{str(network_ip)}'
             conf.set(base_addr_type + ['address'], value=network)
     return
 
 
-if __name__ == '__main__':
-    if len(argv) < 2:
-        print("Must specify file name!")
-        exit(1)
-
-    file_name = argv[1]
-
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
-
+def migrate(config: ConfigTree) -> None:
     if not config.exists(['nat']):
         # Nothing to do
-        exit(0)
+        return
 
     for direction in ['source', 'destination']:
         # If a node doesn't exist, we obviously have nothing to do.
         if not config.exists(['nat', direction]):
             continue
 
         # However, we also need to handle the case when a 'source' or 'destination' sub-node does exist,
         # but there are no rules under it.
         if not config.list_nodes(['nat', direction]):
             continue
 
         for rule in config.list_nodes(['nat', direction, 'rule']):
             base = ['nat', direction, 'rule', rule]
             _func_T5643(config,base)
             _func_T6100(config,base)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/nat/6-to-7 b/src/migration-scripts/nat/6-to-7
old mode 100755
new mode 100644
index 25640dec2..e9b90fc98
--- a/src/migration-scripts/nat/6-to-7
+++ b/src/migration-scripts/nat/6-to-7
@@ -1,73 +1,54 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5681: Firewall re-writing. Simplify cli when mathcing interface
 # From
 #   'set nat [source|destination] rule X [inbound-interface|outbound interface] interface-name <iface>'
 #   'set nat [source|destination] rule X [inbound-interface|outbound interface] interface-group <iface_group>'
 # to
 #   'set nat [source|destination] rule X [inbound-interface|outbound interface] name <iface>'
 #   'set nat [source|destination] rule X [inbound-interface|outbound interface] group <iface_group>'
 # Also remove command if interface == any
 
-from sys import argv,exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
-if not config.exists(['nat']):
-    # Nothing to do
-    exit(0)
-
-for direction in ['source', 'destination']:
-    # If a node doesn't exist, we obviously have nothing to do.
-    if not config.exists(['nat', direction]):
-        continue
-
-    # However, we also need to handle the case when a 'source' or 'destination' sub-node does exist,
-    # but there are no rules under it.
-    if not config.list_nodes(['nat', direction]):
-        continue
-
-    for rule in config.list_nodes(['nat', direction, 'rule']):
-        base = ['nat', direction, 'rule', rule]
-        for iface in ['inbound-interface','outbound-interface']:
-            if config.exists(base + [iface]):
-                if config.exists(base + [iface, 'interface-name']):
-                    tmp = config.return_value(base + [iface, 'interface-name'])
-                    if tmp != 'any':
-                        config.delete(base + [iface, 'interface-name'])
-                        if '+' in tmp:
-                            tmp = tmp.replace('+', '*')
-                        config.set(base + [iface, 'name'], value=tmp)
-                    else:
-                        config.delete(base + [iface])
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(['nat']):
+        # Nothing to do
+        return
+
+    for direction in ['source', 'destination']:
+        # If a node doesn't exist, we obviously have nothing to do.
+        if not config.exists(['nat', direction]):
+            continue
+
+        # However, we also need to handle the case when a 'source' or 'destination' sub-node does exist,
+        # but there are no rules under it.
+        if not config.list_nodes(['nat', direction]):
+            continue
+
+        for rule in config.list_nodes(['nat', direction, 'rule']):
+            base = ['nat', direction, 'rule', rule]
+            for iface in ['inbound-interface','outbound-interface']:
+                if config.exists(base + [iface]):
+                    if config.exists(base + [iface, 'interface-name']):
+                        tmp = config.return_value(base + [iface, 'interface-name'])
+                        if tmp != 'any':
+                            config.delete(base + [iface, 'interface-name'])
+                            if '+' in tmp:
+                                tmp = tmp.replace('+', '*')
+                            config.set(base + [iface, 'name'], value=tmp)
+                        else:
+                            config.delete(base + [iface])
diff --git a/src/migration-scripts/nat/7-to-8 b/src/migration-scripts/nat/7-to-8
old mode 100755
new mode 100644
index ab2ffa6d3..9ae389ef1
--- a/src/migration-scripts/nat/7-to-8
+++ b/src/migration-scripts/nat/7-to-8
@@ -1,62 +1,43 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T6345: random - In kernel 5.0 and newer this is the same as fully-random.
 #        In earlier kernels the port mapping will be randomized using a seeded
 #        MD5 hash mix using source and destination address and destination port.
 #        drop fully-random from CLI
 
-from sys import argv,exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
-if not config.exists(['nat']):
-    # Nothing to do
-    exit(0)
-
-for direction in ['source', 'destination']:
-    # If a node doesn't exist, we obviously have nothing to do.
-    if not config.exists(['nat', direction]):
-        continue
-
-    # However, we also need to handle the case when a 'source' or 'destination' sub-node does exist,
-    # but there are no rules under it.
-    if not config.list_nodes(['nat', direction]):
-        continue
-
-    for rule in config.list_nodes(['nat', direction, 'rule']):
-        port_mapping = ['nat', direction, 'rule', rule, 'translation', 'options', 'port-mapping']
-        if config.exists(port_mapping):
-            tmp = config.return_value(port_mapping)
-            if tmp == 'fully-random':
-                config.set(port_mapping, value='random')
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(['nat']):
+        # Nothing to do
+        return
+
+    for direction in ['source', 'destination']:
+        # If a node doesn't exist, we obviously have nothing to do.
+        if not config.exists(['nat', direction]):
+            continue
+
+        # However, we also need to handle the case when a 'source' or 'destination' sub-node does exist,
+        # but there are no rules under it.
+        if not config.list_nodes(['nat', direction]):
+            continue
+
+        for rule in config.list_nodes(['nat', direction, 'rule']):
+            port_mapping = ['nat', direction, 'rule', rule, 'translation', 'options', 'port-mapping']
+            if config.exists(port_mapping):
+                tmp = config.return_value(port_mapping)
+                if tmp == 'fully-random':
+                    config.set(port_mapping, value='random')
diff --git a/src/migration-scripts/nat66/0-to-1 b/src/migration-scripts/nat66/0-to-1
old mode 100755
new mode 100644
index 444b2315f..b3c6bf4cc
--- a/src/migration-scripts/nat66/0-to-1
+++ b/src/migration-scripts/nat66/0-to-1
@@ -1,71 +1,52 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-from sys import argv,exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 def merge_npt(config,base,rule):
     merge_base = ['nat66','source','rule',rule]
     # Configure migration functions
     if config.exists(base + ['description']):
         tmp = config.return_value(base + ['description'])
         config.set(merge_base + ['description'],value=tmp)
 
     if config.exists(base + ['disable']):
         tmp = config.return_value(base + ['disable'])
         config.set(merge_base + ['disable'],value=tmp)
 
     if config.exists(base + ['outbound-interface']):
         tmp = config.return_value(base + ['outbound-interface'])
         config.set(merge_base + ['outbound-interface'],value=tmp)
 
     if config.exists(base + ['source','prefix']):
         tmp = config.return_value(base + ['source','prefix'])
         config.set(merge_base + ['source','prefix'],value=tmp)
 
     if config.exists(base + ['translation','prefix']):
         tmp = config.return_value(base + ['translation','prefix'])
         config.set(merge_base + ['translation','address'],value=tmp)
 
-if not config.exists(['nat', 'nptv6']):
-    # Nothing to do
-    exit(0)
-
-for rule in config.list_nodes(['nat', 'nptv6', 'rule']):
-    base = ['nat', 'nptv6', 'rule', rule]
-    # Merge 'nat nptv6' to 'nat66 source'
-    merge_npt(config,base,rule)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(['nat', 'nptv6']):
+        # Nothing to do
+        return
 
-# Delete the original NPT configuration
-config.delete(['nat','nptv6']);
+    for rule in config.list_nodes(['nat', 'nptv6', 'rule']):
+        base = ['nat', 'nptv6', 'rule', rule]
+        # Merge 'nat nptv6' to 'nat66 source'
+        merge_npt(config,base,rule)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    # Delete the original NPT configuration
+    config.delete(['nat','nptv6']);
diff --git a/src/migration-scripts/nat66/1-to-2 b/src/migration-scripts/nat66/1-to-2
old mode 100755
new mode 100644
index b7d4e3f6b..f49940ae0
--- a/src/migration-scripts/nat66/1-to-2
+++ b/src/migration-scripts/nat66/1-to-2
@@ -1,63 +1,61 @@
 #!/usr/bin/env python3
 #
 # Copyright (C) 2023 VyOS maintainers and contributors
 #
 # This program is free software; you can redistribute it and/or modify
 # it under the terms of the GNU General Public License version 2 or later as
 # published by the Free Software Foundation.
 #
 # This program 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 for more details.
 #
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
+
 # T5681: Firewall re-writing. Simplify cli when mathcing interface
 # From
 #   'set nat66 [source|destination] rule X [inbound-interface|outbound interface] <iface>'
 # to
 #   'set nat66 [source|destination] rule X [inbound-interface|outbound interface] name <iface>'
 
-from sys import argv,exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-if not config.exists(['nat66']):
-    # Nothing to do
-    exit(0)
-
-for direction in ['source', 'destination']:
-    # If a node doesn't exist, we obviously have nothing to do.
-    if not config.exists(['nat66', direction]):
-        continue
-
-    # However, we also need to handle the case when a 'source' or 'destination' sub-node does exist,
-    # but there are no rules under it.
-    if not config.list_nodes(['nat66', direction]):
-        continue
-
-    for rule in config.list_nodes(['nat66', direction, 'rule']):
-        base = ['nat66', direction, 'rule', rule]
-        for iface in ['inbound-interface','outbound-interface']:
-            if config.exists(base + [iface]):
-                tmp = config.return_value(base + [iface])
-                config.delete(base + [iface])
-                config.set(base + [iface, 'name'], value=tmp)
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(['nat66']):
+        # Nothing to do
+        return
+
+    for direction in ['source', 'destination']:
+        # If a node doesn't exist, we obviously have nothing to do.
+        if not config.exists(['nat66', direction]):
+            continue
+
+        # However, we also need to handle the case when a 'source' or 'destination' sub-node does exist,
+        # but there are no rules under it.
+        if not config.list_nodes(['nat66', direction]):
+            continue
+
+        for rule in config.list_nodes(['nat66', direction, 'rule']):
+            base = ['nat66', direction, 'rule', rule]
+            for iface in ['inbound-interface','outbound-interface']:
+                if config.exists(base + [iface]):
+                    tmp = config.return_value(base + [iface])
+                    config.delete(base + [iface])
+                    config.set(base + [iface, 'name'], value=tmp)
diff --git a/src/migration-scripts/nat66/2-to-3 b/src/migration-scripts/nat66/2-to-3
old mode 100755
new mode 100644
index f34f170b3..55d5f4b2b
--- a/src/migration-scripts/nat66/2-to-3
+++ b/src/migration-scripts/nat66/2-to-3
@@ -1,61 +1,45 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sys import argv,exit
-from vyos.configtree import ConfigTree
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
+# T2898: add ndp-proxy service
 
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
+from vyos.configtree import ConfigTree
 
 base = ['nat66', 'source']
 new_base = ['service', 'ndp-proxy', 'interface']
 
-config = ConfigTree(config_file)
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-for rule in config.list_nodes(base + ['rule']):
-    base_rule = base + ['rule', rule]
-
-    interface = None
-    if config.exists(base_rule + ['outbound-interface', 'name']):
-        interface = config.return_value(base_rule + ['outbound-interface', 'name'])
-    else:
-        continue
-
-    prefix_base = base_rule + ['source', 'prefix']
-    if config.exists(prefix_base):
-        prefix = config.return_value(prefix_base)
-        config.set(new_base + [interface, 'prefix', prefix, 'mode'], value='static')
-        config.set_tag(new_base)
-        config.set_tag(new_base + [interface, 'prefix'])
-
-        if config.exists(base_rule + ['disable']):
-            config.set(new_base + [interface, 'prefix', prefix, 'disable'])
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    for rule in config.list_nodes(base + ['rule']):
+        base_rule = base + ['rule', rule]
+
+        interface = None
+        if config.exists(base_rule + ['outbound-interface', 'name']):
+            interface = config.return_value(base_rule + ['outbound-interface', 'name'])
+        else:
+            continue
+
+        prefix_base = base_rule + ['source', 'prefix']
+        if config.exists(prefix_base):
+            prefix = config.return_value(prefix_base)
+            config.set(new_base + [interface, 'prefix', prefix, 'mode'], value='static')
+            config.set_tag(new_base)
+            config.set_tag(new_base + [interface, 'prefix'])
+
+            if config.exists(base_rule + ['disable']):
+                config.set(new_base + [interface, 'prefix', prefix, 'disable'])
diff --git a/src/migration-scripts/ntp/0-to-1 b/src/migration-scripts/ntp/0-to-1
old mode 100755
new mode 100644
index cbce45b9b..01f5a460a
--- a/src/migration-scripts/ntp/0-to-1
+++ b/src/migration-scripts/ntp/0-to-1
@@ -1,36 +1,32 @@
 #!/usr/bin/env python3
 
-# Delete "set system ntp server <n> dynamic" option
+# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-import sys
+# Delete "set system ntp server <n> dynamic" option
 
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(['system', 'ntp', 'server']):
+        # Nothing to do
+        return
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
-if not config.exists(['system', 'ntp', 'server']):
-    # Nothing to do
-    sys.exit(0)
-else:
     # Delete abandoned leaf node if found inside tag node for
     # "set system ntp server <n> dynamic"
     base = ['system', 'ntp', 'server']
     for server in config.list_nodes(base):
         if config.exists(base + [server, 'dynamic']):
             config.delete(base + [server, 'dynamic'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/ntp/1-to-2 b/src/migration-scripts/ntp/1-to-2
old mode 100755
new mode 100644
index fd1f15d91..fd7b08221
--- a/src/migration-scripts/ntp/1-to-2
+++ b/src/migration-scripts/ntp/1-to-2
@@ -1,72 +1,53 @@
-#!/usr/bin/env python3
-
-# Copyright (C) 2023 VyOS maintainers and contributors
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3008: move from ntpd to chrony and migrate "system ntp" to "service ntp"
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 base_path = ['system', 'ntp']
 new_base_path = ['service', 'ntp']
-if not config.exists(base_path):
-    # Nothing to do
-    sys.exit(0)
-
-# config.copy does not recursively create a path, so create ['service'] if
-# it doesn't yet exist, such as for config.boot.default
-if not config.exists(['service']):
-    config.set(['service'])
-
-# copy "system ntp" to "service ntp"
-config.copy(base_path, new_base_path)
-config.delete(base_path)
-
-# chrony does not support the preempt option, drop it
-for server in config.list_nodes(new_base_path + ['server']):
-    server_base =  new_base_path + ['server', server]
-    if config.exists(server_base + ['preempt']):
-        config.delete(server_base + ['preempt'])
-
-# Rename "allow-clients" -> "allow-client"
-if config.exists(new_base_path + ['allow-clients']):
-    config.rename(new_base_path + ['allow-clients'], 'allow-client')
-
-# By default VyOS 1.3 allowed NTP queries for all networks - in chrony we
-# explicitly disable this behavior and clients need to be specified using the
-# allow-client CLI option. In order to be fully backwards compatible, we specify
-# 0.0.0.0/0 and ::/0 as allow networks if not specified otherwise explicitly.
-if not config.exists(new_base_path + ['allow-client']):
-    config.set(new_base_path + ['allow-client', 'address'], value='0.0.0.0/0', replace=False)
-    config.set(new_base_path + ['allow-client', 'address'], value='::/0', replace=False)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base_path):
+        # Nothing to do
+        return
+
+    # config.copy does not recursively create a path, so create ['service'] if
+    # it doesn't yet exist, such as for config.boot.default
+    if not config.exists(['service']):
+        config.set(['service'])
+
+    # copy "system ntp" to "service ntp"
+    config.copy(base_path, new_base_path)
+    config.delete(base_path)
+
+    # chrony does not support the preempt option, drop it
+    for server in config.list_nodes(new_base_path + ['server']):
+        server_base =  new_base_path + ['server', server]
+        if config.exists(server_base + ['preempt']):
+            config.delete(server_base + ['preempt'])
+
+    # Rename "allow-clients" -> "allow-client"
+    if config.exists(new_base_path + ['allow-clients']):
+        config.rename(new_base_path + ['allow-clients'], 'allow-client')
+
+    # By default VyOS 1.3 allowed NTP queries for all networks - in chrony we
+    # explicitly disable this behavior and clients need to be specified using the
+    # allow-client CLI option. In order to be fully backwards compatible, we specify
+    # 0.0.0.0/0 and ::/0 as allow networks if not specified otherwise explicitly.
+    if not config.exists(new_base_path + ['allow-client']):
+        config.set(new_base_path + ['allow-client', 'address'], value='0.0.0.0/0', replace=False)
+        config.set(new_base_path + ['allow-client', 'address'], value='::/0', replace=False)
diff --git a/src/migration-scripts/ntp/2-to-3 b/src/migration-scripts/ntp/2-to-3
old mode 100755
new mode 100644
index a4351845e..bbda90351
--- a/src/migration-scripts/ntp/2-to-3
+++ b/src/migration-scripts/ntp/2-to-3
@@ -1,62 +1,43 @@
-#!/usr/bin/env python3
-
-# Copyright (C) 2023 VyOS maintainers and contributors
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5154: allow only one ip address per family for parameter 'listen-address'
 # Allow only one interface for parameter 'interface'
 # If more than one are specified, remove such entries
 
-import sys
-
 from vyos.configtree import ConfigTree
 from vyos.template import is_ipv4
 from vyos.template import is_ipv6
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 base_path = ['service', 'ntp']
-if not config.exists(base_path):
-    # Nothing to do
-    sys.exit(0)
 
-if config.exists(base_path + ['listen-address']) and (len([addr for addr in config.return_values(base_path + ['listen-address']) if is_ipv4(addr)]) > 1):
-    for addr in config.return_values(base_path + ['listen-address']):
-        if is_ipv4(addr):
-            config.delete_value(base_path + ['listen-address'], addr)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base_path):
+        # Nothing to do
+        return
 
-if config.exists(base_path + ['listen-address']) and (len([addr for addr in config.return_values(base_path + ['listen-address']) if is_ipv6(addr)]) > 1):
-    for addr in config.return_values(base_path + ['listen-address']):
-        if is_ipv6(addr):
-            config.delete_value(base_path + ['listen-address'], addr)
+    if config.exists(base_path + ['listen-address']) and (len([addr for addr in config.return_values(base_path + ['listen-address']) if is_ipv4(addr)]) > 1):
+        for addr in config.return_values(base_path + ['listen-address']):
+            if is_ipv4(addr):
+                config.delete_value(base_path + ['listen-address'], addr)
 
-if config.exists(base_path + ['interface']):
-    if len(config.return_values(base_path + ['interface'])) > 1:
-        config.delete(base_path + ['interface'])
+    if config.exists(base_path + ['listen-address']) and (len([addr for addr in config.return_values(base_path + ['listen-address']) if is_ipv6(addr)]) > 1):
+        for addr in config.return_values(base_path + ['listen-address']):
+            if is_ipv6(addr):
+                config.delete_value(base_path + ['listen-address'], addr)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+    if config.exists(base_path + ['interface']):
+        if len(config.return_values(base_path + ['interface'])) > 1:
+            config.delete(base_path + ['interface'])
diff --git a/src/migration-scripts/openconnect/0-to-1 b/src/migration-scripts/openconnect/0-to-1
old mode 100755
new mode 100644
index c64b16cb2..aa5a97eee
--- a/src/migration-scripts/openconnect/0-to-1
+++ b/src/migration-scripts/openconnect/0-to-1
@@ -1,135 +1,116 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - Update SSL to use PKI configuration
 
 import os
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 from vyos.pki import load_certificate
 from vyos.pki import load_private_key
 from vyos.pki import encode_certificate
 from vyos.pki import encode_private_key
 from vyos.utils.process import run
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['vpn', 'openconnect']
 pki_base = ['pki']
 
-if not config.exists(base):
-    exit(0)
-
 AUTH_DIR = '/config/auth'
 
 def wrapped_pem_to_config_value(pem):
     return "".join(pem.strip().split("\n")[1:-1])
 
-if not config.exists(base + ['ssl']):
-    exit(0)
-
-x509_base = base + ['ssl']
-pki_name = 'openconnect'
-
-if not config.exists(pki_base + ['ca']):
-    config.set(pki_base + ['ca'])
-    config.set_tag(pki_base + ['ca'])
-
-if not config.exists(pki_base + ['certificate']):
-    config.set(pki_base + ['certificate'])
-    config.set_tag(pki_base + ['certificate'])
-
-if config.exists(x509_base + ['ca-cert-file']):
-    cert_file = config.return_value(x509_base + ['ca-cert-file'])
-    cert_path = os.path.join(AUTH_DIR, cert_file)
-    cert = None
-
-    if os.path.isfile(cert_path):
-        if not os.access(cert_path, os.R_OK):
-            run(f'sudo chmod 644 {cert_path}')
-
-        with open(cert_path, 'r') as f:
-            cert_data = f.read()
-            cert = load_certificate(cert_data, wrap_tags=False)
-
-    if cert:
-        cert_pem = encode_certificate(cert)
-        config.set(pki_base + ['ca', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
-        config.set(x509_base + ['ca-certificate'], value=pki_name)
-    else:
-        print(f'Failed to migrate CA certificate on openconnect config')
-
-    config.delete(x509_base + ['ca-cert-file'])
-
-if config.exists(x509_base + ['cert-file']):
-    cert_file = config.return_value(x509_base + ['cert-file'])
-    cert_path = os.path.join(AUTH_DIR, cert_file)
-    cert = None
-
-    if os.path.isfile(cert_path):
-        if not os.access(cert_path, os.R_OK):
-            run(f'sudo chmod 644 {cert_path}')
-
-        with open(cert_path, 'r') as f:
-            cert_data = f.read()
-            cert = load_certificate(cert_data, wrap_tags=False)
-
-    if cert:
-        cert_pem = encode_certificate(cert)
-        config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
-        config.set(x509_base + ['certificate'], value=pki_name)
-    else:
-        print(f'Failed to migrate certificate on openconnect config')
-
-    config.delete(x509_base + ['cert-file'])
-
-if config.exists(x509_base + ['key-file']):
-    key_file = config.return_value(x509_base + ['key-file'])
-    key_path = os.path.join(AUTH_DIR, key_file)
-    key = None
-
-    if os.path.isfile(key_path):
-        if not os.access(key_path, os.R_OK):
-            run(f'sudo chmod 644 {key_path}')
-
-        with open(key_path, 'r') as f:
-            key_data = f.read()
-            key = load_private_key(key_data, passphrase=None, wrap_tags=False)
-
-    if key:
-        key_pem = encode_private_key(key, passphrase=None)
-        config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
-    else:
-        print(f'Failed to migrate private key on openconnect config')
-
-    config.delete(x509_base + ['key-file'])
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
+
+    if not config.exists(base + ['ssl']):
+        return
+
+    x509_base = base + ['ssl']
+    pki_name = 'openconnect'
+
+    if not config.exists(pki_base + ['ca']):
+        config.set(pki_base + ['ca'])
+        config.set_tag(pki_base + ['ca'])
+
+    if not config.exists(pki_base + ['certificate']):
+        config.set(pki_base + ['certificate'])
+        config.set_tag(pki_base + ['certificate'])
+
+    if config.exists(x509_base + ['ca-cert-file']):
+        cert_file = config.return_value(x509_base + ['ca-cert-file'])
+        cert_path = os.path.join(AUTH_DIR, cert_file)
+        cert = None
+
+        if os.path.isfile(cert_path):
+            if not os.access(cert_path, os.R_OK):
+                run(f'sudo chmod 644 {cert_path}')
+
+            with open(cert_path, 'r') as f:
+                cert_data = f.read()
+                cert = load_certificate(cert_data, wrap_tags=False)
+
+        if cert:
+            cert_pem = encode_certificate(cert)
+            config.set(pki_base + ['ca', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+            config.set(x509_base + ['ca-certificate'], value=pki_name)
+        else:
+            print(f'Failed to migrate CA certificate on openconnect config')
+
+        config.delete(x509_base + ['ca-cert-file'])
+
+    if config.exists(x509_base + ['cert-file']):
+        cert_file = config.return_value(x509_base + ['cert-file'])
+        cert_path = os.path.join(AUTH_DIR, cert_file)
+        cert = None
+
+        if os.path.isfile(cert_path):
+            if not os.access(cert_path, os.R_OK):
+                run(f'sudo chmod 644 {cert_path}')
+
+            with open(cert_path, 'r') as f:
+                cert_data = f.read()
+                cert = load_certificate(cert_data, wrap_tags=False)
+
+        if cert:
+            cert_pem = encode_certificate(cert)
+            config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+            config.set(x509_base + ['certificate'], value=pki_name)
+        else:
+            print(f'Failed to migrate certificate on openconnect config')
+
+        config.delete(x509_base + ['cert-file'])
+
+    if config.exists(x509_base + ['key-file']):
+        key_file = config.return_value(x509_base + ['key-file'])
+        key_path = os.path.join(AUTH_DIR, key_file)
+        key = None
+
+        if os.path.isfile(key_path):
+            if not os.access(key_path, os.R_OK):
+                run(f'sudo chmod 644 {key_path}')
+
+            with open(key_path, 'r') as f:
+                key_data = f.read()
+                key = load_private_key(key_data, passphrase=None, wrap_tags=False)
+
+        if key:
+            key_pem = encode_private_key(key, passphrase=None)
+            config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
+        else:
+            print(f'Failed to migrate private key on openconnect config')
+
+        config.delete(x509_base + ['key-file'])
diff --git a/src/migration-scripts/openconnect/1-to-2 b/src/migration-scripts/openconnect/1-to-2
old mode 100755
new mode 100644
index 7978aa56e..4f74b44df
--- a/src/migration-scripts/openconnect/1-to-2
+++ b/src/migration-scripts/openconnect/1-to-2
@@ -1,54 +1,35 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Delete depricated outside-nexthop address
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 cfg_base = ['vpn', 'openconnect']
 
-if not config.exists(cfg_base):
-    # Nothing to do
-    sys.exit(0)
-else:
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(cfg_base):
+        # Nothing to do
+        return
+
     if config.exists(cfg_base + ['authentication', 'mode']):
         if config.return_value(cfg_base + ['authentication', 'mode']) == 'radius':
             # if "mode value radius", change to "mode + valueless node radius"
             config.delete_value(cfg_base + ['authentication','mode'], 'radius')
             config.set(cfg_base + ['authentication', 'mode', 'radius'], value=None)
         elif config.return_value(cfg_base + ['authentication', 'mode']) == 'local':
             # if "mode local", change to "mode + node local value password"
             config.delete_value(cfg_base + ['authentication', 'mode'], 'local')
             config.set(cfg_base + ['authentication', 'mode', 'local'], value='password')
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/openconnect/2-to-3 b/src/migration-scripts/openconnect/2-to-3
old mode 100755
new mode 100644
index e78fc8a91..00e13ecb0
--- a/src/migration-scripts/openconnect/2-to-3
+++ b/src/migration-scripts/openconnect/2-to-3
@@ -1,50 +1,30 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T4982: Retain prior default TLS version (v1.0) when upgrading installations with existing openconnect configurations
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-
-config = ConfigTree(config_file)
 cfg_base = ['vpn', 'openconnect']
 
-# bail out early if service is unconfigured
-if not config.exists(cfg_base):
-    sys.exit(0)
-
-# new default is TLS 1.2 - set explicit old default value of TLS 1.0 for upgraded configurations to keep compatibility
-tls_min_path = cfg_base + ['tls-version-min']
-if not config.exists(tls_min_path):
-    config.set(tls_min_path, value='1.0')
+def migrate(config: ConfigTree) -> None:
+    # bail out early if service is unconfigured
+    if not config.exists(cfg_base):
+        return
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+    # new default is TLS 1.2 - set explicit old default value of TLS 1.0 for upgraded configurations to keep compatibility
+    tls_min_path = cfg_base + ['tls-version-min']
+    if not config.exists(tls_min_path):
+        config.set(tls_min_path, value='1.0')
diff --git a/src/migration-scripts/openvpn/0-to-1 b/src/migration-scripts/openvpn/0-to-1
old mode 100755
new mode 100644
index 24bb38d3c..e5db731ed
--- a/src/migration-scripts/openvpn/0-to-1
+++ b/src/migration-scripts/openvpn/0-to-1
@@ -1,49 +1,43 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Removes outdated ciphers (DES and Blowfish) from OpenVPN configs
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(['interfaces', 'openvpn']):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-
-if not config.exists(['interfaces', 'openvpn']):
-    # Nothing to do
-    sys.exit(0)
-else:
     ovpn_intfs = config.list_nodes(['interfaces', 'openvpn'])
     for	i in ovpn_intfs:
         # Remove DES and Blowfish from 'encryption cipher'
         cipher_path = ['interfaces', 'openvpn', i, 'encryption', 'cipher']
         if config.exists(cipher_path):
             cipher = config.return_value(cipher_path)
             if cipher in ['des', 'bf128', 'bf256']:
                 config.delete(cipher_path)
 
         ncp_cipher_path = ['interfaces', 'openvpn', i, 'encryption', 'ncp-ciphers']
         if config.exists(ncp_cipher_path):
             ncp_ciphers = config.return_values(['interfaces', 'openvpn', i, 'encryption', 'ncp-ciphers'])
             if 'des' in ncp_ciphers:
                 config.delete_value(['interfaces', 'openvpn', i, 'encryption', 'ncp-ciphers'], 'des')
 
         # Clean up the encryption subtree if the migration procedure left it empty
         if config.exists(['interfaces', 'openvpn', i, 'encryption']) and \
            (config.list_nodes(['interfaces', 'openvpn', i, 'encryption']) == []):
             config.delete(['interfaces', 'openvpn', i, 'encryption'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/openvpn/1-to-2 b/src/migration-scripts/openvpn/1-to-2
old mode 100755
new mode 100644
index 1f82a2128..b7b7d4c77
--- a/src/migration-scripts/openvpn/1-to-2
+++ b/src/migration-scripts/openvpn/1-to-2
@@ -1,74 +1,55 @@
 #!/usr/bin/env python3
 #
 # Copyright (C) 2024 VyOS maintainers and contributors
 #
 # This program is free software; you can redistribute it and/or modify
 # it under the terms of the GNU General Public License version 2 or later as
 # published by the Free Software Foundation.
 #
 # This program 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 for more details.
 #
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 # Removes --cipher option (deprecated) from OpenVPN configs
 # and moves it to --data-ciphers for server and client modes
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(['interfaces', 'openvpn']):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-
-if not config.exists(['interfaces', 'openvpn']):
-    # Nothing to do
-    sys.exit(0)
-else:
     ovpn_intfs = config.list_nodes(['interfaces', 'openvpn'])
     for	i in ovpn_intfs:
         # Remove 'encryption cipher' and add this value to 'encryption ncp-ciphers'
         # for server and client mode.
         # Site-to-site mode still can use --cipher option
         cipher_path = ['interfaces', 'openvpn', i, 'encryption', 'cipher']
         ncp_cipher_path = ['interfaces', 'openvpn', i, 'encryption', 'ncp-ciphers']
         if config.exists(cipher_path):
             if config.exists(['interfaces', 'openvpn', i, 'shared-secret-key']):
                 continue
             cipher = config.return_value(cipher_path)
             config.delete(cipher_path)
             if cipher == 'none':
                 if not config.exists(ncp_cipher_path):
                     config.delete(['interfaces', 'openvpn', i, 'encryption'])
                 continue
 
             ncp_ciphers = []
             if config.exists(ncp_cipher_path):
                 ncp_ciphers = config.return_values(ncp_cipher_path)
                 config.delete(ncp_cipher_path)
 
             # need to add the deleted cipher at the first place in the list
             if cipher in ncp_ciphers:
                 ncp_ciphers.remove(cipher)
             ncp_ciphers.insert(0, cipher)
 
             for c in ncp_ciphers:
                 config.set(ncp_cipher_path, value=c, replace=False)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/ospf/0-to-1 b/src/migration-scripts/ospf/0-to-1
old mode 100755
new mode 100644
index 4085423a2..a1f810960
--- a/src/migration-scripts/ospf/0-to-1
+++ b/src/migration-scripts/ospf/0-to-1
@@ -1,84 +1,66 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3753: upgrade to FRR8 and move CLI options to better fit with the new FRR CLI
 
-from sys import argv
 from vyos.configtree import ConfigTree
 
 def ospf_passive_migration(config, ospf_base):
     if config.exists(ospf_base):
         if config.exists(ospf_base + ['passive-interface']):
             default = False
             for interface in config.return_values(ospf_base + ['passive-interface']):
                 if interface == 'default':
                     default = True
                     continue
                 config.set(ospf_base + ['interface', interface, 'passive'])
                 config.set_tag(ospf_base + ['interface'])
 
             config.delete(ospf_base + ['passive-interface'])
             if default:
                 config.set(ospf_base + ['passive-interface'], value='default')
 
         if config.exists(ospf_base + ['passive-interface-exclude']):
             for interface in config.return_values(ospf_base + ['passive-interface-exclude']):
                 config.set(ospf_base + ['interface', interface, 'passive', 'disable'])
                 config.set_tag(ospf_base + ['interface'])
             config.delete(ospf_base + ['passive-interface-exclude'])
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 ospfv3_base = ['protocols', 'ospfv3']
-if config.exists(ospfv3_base):
-    area_base = ospfv3_base + ['area']
-    if config.exists(area_base):
-        for area in config.list_nodes(area_base):
-            if not config.exists(area_base + [area, 'interface']):
-                continue
 
-            for interface in config.return_values(area_base + [area, 'interface']):
-                config.set(ospfv3_base + ['interface', interface, 'area'], value=area)
-                config.set_tag(ospfv3_base + ['interface'])
+def migrate(config: ConfigTree) -> None:
+    if config.exists(ospfv3_base):
+        area_base = ospfv3_base + ['area']
+        if config.exists(area_base):
+            for area in config.list_nodes(area_base):
+                if not config.exists(area_base + [area, 'interface']):
+                    continue
 
-            config.delete(area_base + [area, 'interface'])
+                for interface in config.return_values(area_base + [area, 'interface']):
+                    config.set(ospfv3_base + ['interface', interface, 'area'], value=area)
+                    config.set_tag(ospfv3_base + ['interface'])
 
-# Migrate OSPF syntax in default VRF
-ospf_base = ['protocols', 'ospf']
-ospf_passive_migration(config, ospf_base)
+                config.delete(area_base + [area, 'interface'])
 
-vrf_base = ['vrf', 'name']
-if config.exists(vrf_base):
-    for vrf in config.list_nodes(vrf_base):
-        vrf_ospf_base = vrf_base + [vrf, 'protocols', 'ospf']
-        if config.exists(vrf_ospf_base):
-            ospf_passive_migration(config, vrf_ospf_base)
+    # Migrate OSPF syntax in default VRF
+    ospf_base = ['protocols', 'ospf']
+    ospf_passive_migration(config, ospf_base)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+    vrf_base = ['vrf', 'name']
+    if config.exists(vrf_base):
+        for vrf in config.list_nodes(vrf_base):
+            vrf_ospf_base = vrf_base + [vrf, 'protocols', 'ospf']
+            if config.exists(vrf_ospf_base):
+                ospf_passive_migration(config, vrf_ospf_base)
diff --git a/src/migration-scripts/ospf/1-to-2 b/src/migration-scripts/ospf/1-to-2
old mode 100755
new mode 100644
index ba9499c60..5368d8dd7
--- a/src/migration-scripts/ospf/1-to-2
+++ b/src/migration-scripts/ospf/1-to-2
@@ -1,80 +1,60 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5150: Rework CLI definitions to apply route-maps between routing daemons
 #        and zebra/kernel
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 ospf_base = ['protocols', 'ospf']
-# Check if OSPF is configured - if so, migrate the CLI node
-if config.exists(ospf_base):
-    if config.exists(ospf_base + ['route-map']):
-        tmp = config.return_value(ospf_base + ['route-map'])
-
-        config.set(['system', 'ip', 'protocol', 'ospf', 'route-map'], value=tmp)
-        config.set_tag(['system', 'ip', 'protocol'])
-        config.delete(ospf_base + ['route-map'])
-
-ospfv3_base = ['protocols', 'ospfv3']
-# Check if OSPFv3 is configured - if so, migrate the CLI node
-if config.exists(ospfv3_base):
-    if config.exists(ospfv3_base + ['route-map']):
-        tmp = config.return_value(ospfv3_base + ['route-map'])
-
-        config.set(['system', 'ipv6', 'protocol', 'ospfv3', 'route-map'], value=tmp)
-        config.set_tag(['system', 'ipv6', 'protocol'])
-        config.delete(ospfv3_base + ['route-map'])
-
-# Check if vrf names are configured. Check if OSPF/OSPFv3 is configured - if so,
-# migrate the CLI node(s)
-if config.exists(['vrf', 'name']):
-    for vrf in config.list_nodes(['vrf', 'name']):
-        vrf_base = ['vrf', 'name', vrf]
-        if config.exists(vrf_base + ['protocols', 'ospf', 'route-map']):
-            tmp = config.return_value(vrf_base + ['protocols', 'ospf', 'route-map'])
-
-            config.set(vrf_base + ['ip', 'protocol', 'ospf', 'route-map'], value=tmp)
-            config.set_tag(vrf_base + ['ip', 'protocol', 'ospf'])
-            config.delete(vrf_base + ['protocols', 'ospf', 'route-map'])
-
-        if config.exists(vrf_base + ['protocols', 'ospfv3', 'route-map']):
-            tmp = config.return_value(vrf_base + ['protocols', 'ospfv3', 'route-map'])
-
-            config.set(vrf_base + ['ipv6', 'protocol', 'ospfv3', 'route-map'], value=tmp)
-            config.set_tag(vrf_base + ['ipv6', 'protocol', 'ospfv6'])
-            config.delete(vrf_base + ['protocols', 'ospfv3', 'route-map'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    # Check if OSPF is configured - if so, migrate the CLI node
+    if config.exists(ospf_base):
+        if config.exists(ospf_base + ['route-map']):
+            tmp = config.return_value(ospf_base + ['route-map'])
+
+            config.set(['system', 'ip', 'protocol', 'ospf', 'route-map'], value=tmp)
+            config.set_tag(['system', 'ip', 'protocol'])
+            config.delete(ospf_base + ['route-map'])
+
+    ospfv3_base = ['protocols', 'ospfv3']
+    # Check if OSPFv3 is configured - if so, migrate the CLI node
+    if config.exists(ospfv3_base):
+        if config.exists(ospfv3_base + ['route-map']):
+            tmp = config.return_value(ospfv3_base + ['route-map'])
+
+            config.set(['system', 'ipv6', 'protocol', 'ospfv3', 'route-map'], value=tmp)
+            config.set_tag(['system', 'ipv6', 'protocol'])
+            config.delete(ospfv3_base + ['route-map'])
+
+    # Check if vrf names are configured. Check if OSPF/OSPFv3 is configured - if so,
+    # migrate the CLI node(s)
+    if config.exists(['vrf', 'name']):
+        for vrf in config.list_nodes(['vrf', 'name']):
+            vrf_base = ['vrf', 'name', vrf]
+            if config.exists(vrf_base + ['protocols', 'ospf', 'route-map']):
+                tmp = config.return_value(vrf_base + ['protocols', 'ospf', 'route-map'])
+
+                config.set(vrf_base + ['ip', 'protocol', 'ospf', 'route-map'], value=tmp)
+                config.set_tag(vrf_base + ['ip', 'protocol', 'ospf'])
+                config.delete(vrf_base + ['protocols', 'ospf', 'route-map'])
+
+            if config.exists(vrf_base + ['protocols', 'ospfv3', 'route-map']):
+                tmp = config.return_value(vrf_base + ['protocols', 'ospfv3', 'route-map'])
+
+                config.set(vrf_base + ['ipv6', 'protocol', 'ospfv3', 'route-map'], value=tmp)
+                config.set_tag(vrf_base + ['ipv6', 'protocol', 'ospfv6'])
+                config.delete(vrf_base + ['protocols', 'ospfv3', 'route-map'])
diff --git a/src/migration-scripts/pim/0-to-1 b/src/migration-scripts/pim/0-to-1
old mode 100755
new mode 100644
index bf8af733c..ce24b23ba
--- a/src/migration-scripts/pim/0-to-1
+++ b/src/migration-scripts/pim/0-to-1
@@ -1,72 +1,54 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5736: igmp: migrate "protocols igmp" to "protocols pim"
 
-import sys
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 base = ['protocols', 'igmp']
 pim_base = ['protocols', 'pim']
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
 
-for interface in config.list_nodes(base + ['interface']):
-    base_igmp_iface = base + ['interface', interface]
-    pim_base_iface = pim_base + ['interface', interface]
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-    # Create IGMP note under PIM interface
-    if not config.exists(pim_base_iface + ['igmp']):
-        config.set(pim_base_iface + ['igmp'])
+    for interface in config.list_nodes(base + ['interface']):
+        base_igmp_iface = base + ['interface', interface]
+        pim_base_iface = pim_base + ['interface', interface]
 
-    if config.exists(base_igmp_iface + ['join']):
-        config.copy(base_igmp_iface + ['join'], pim_base_iface + ['igmp', 'join'])
-        config.set_tag(pim_base_iface + ['igmp', 'join'])
+        # Create IGMP note under PIM interface
+        if not config.exists(pim_base_iface + ['igmp']):
+            config.set(pim_base_iface + ['igmp'])
 
-        new_join_base = pim_base_iface + ['igmp', 'join']
-        for address in config.list_nodes(new_join_base):
-            if config.exists(new_join_base + [address, 'source']):
-                config.rename(new_join_base + [address, 'source'], 'source-address')
+        if config.exists(base_igmp_iface + ['join']):
+            config.copy(base_igmp_iface + ['join'], pim_base_iface + ['igmp', 'join'])
+            config.set_tag(pim_base_iface + ['igmp', 'join'])
 
-    if config.exists(base_igmp_iface + ['query-interval']):
-        config.copy(base_igmp_iface + ['query-interval'], pim_base_iface + ['igmp', 'query-interval'])
+            new_join_base = pim_base_iface + ['igmp', 'join']
+            for address in config.list_nodes(new_join_base):
+                if config.exists(new_join_base + [address, 'source']):
+                    config.rename(new_join_base + [address, 'source'], 'source-address')
 
-    if config.exists(base_igmp_iface + ['query-max-response-time']):
-        config.copy(base_igmp_iface + ['query-max-response-time'], pim_base_iface + ['igmp', 'query-max-response-time'])
+        if config.exists(base_igmp_iface + ['query-interval']):
+            config.copy(base_igmp_iface + ['query-interval'], pim_base_iface + ['igmp', 'query-interval'])
 
-    if config.exists(base_igmp_iface + ['version']):
-        config.copy(base_igmp_iface + ['version'], pim_base_iface + ['igmp', 'version'])
+        if config.exists(base_igmp_iface + ['query-max-response-time']):
+            config.copy(base_igmp_iface + ['query-max-response-time'], pim_base_iface + ['igmp', 'query-max-response-time'])
 
-config.delete(base)
+        if config.exists(base_igmp_iface + ['version']):
+            config.copy(base_igmp_iface + ['version'], pim_base_iface + ['igmp', 'version'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+    config.delete(base)
diff --git a/src/migration-scripts/policy/0-to-1 b/src/migration-scripts/policy/0-to-1
old mode 100755
new mode 100644
index 8508b734a..837946c37
--- a/src/migration-scripts/policy/0-to-1
+++ b/src/migration-scripts/policy/0-to-1
@@ -1,65 +1,43 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3631: route-map: migrate "set extcommunity-rt" and "set extcommunity-soo"
 #        to "set extcommunity rt|soo" to match FRR syntax
 
-
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['policy', 'route-map']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
 
-for route_map in config.list_nodes(base):
-    if not config.exists(base + [route_map, 'rule']):
-        continue
-    for rule in config.list_nodes(base + [route_map, 'rule']):
-        base_rule = base + [route_map, 'rule', rule]
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-        if config.exists(base_rule + ['set', 'extcommunity-rt']):
-            tmp = config.return_value(base_rule + ['set', 'extcommunity-rt'])
-            config.delete(base_rule + ['set', 'extcommunity-rt'])
-            config.set(base_rule + ['set', 'extcommunity', 'rt'], value=tmp)
+    for route_map in config.list_nodes(base):
+        if not config.exists(base + [route_map, 'rule']):
+            continue
+        for rule in config.list_nodes(base + [route_map, 'rule']):
+            base_rule = base + [route_map, 'rule', rule]
 
+            if config.exists(base_rule + ['set', 'extcommunity-rt']):
+                tmp = config.return_value(base_rule + ['set', 'extcommunity-rt'])
+                config.delete(base_rule + ['set', 'extcommunity-rt'])
+                config.set(base_rule + ['set', 'extcommunity', 'rt'], value=tmp)
 
-        if config.exists(base_rule + ['set', 'extcommunity-soo']):
-            tmp = config.return_value(base_rule + ['set', 'extcommunity-soo'])
-            config.delete(base_rule + ['set', 'extcommunity-soo'])
-            config.set(base_rule + ['set', 'extcommunity', 'soo'], value=tmp)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+            if config.exists(base_rule + ['set', 'extcommunity-soo']):
+                tmp = config.return_value(base_rule + ['set', 'extcommunity-soo'])
+                config.delete(base_rule + ['set', 'extcommunity-soo'])
+                config.set(base_rule + ['set', 'extcommunity', 'soo'], value=tmp)
diff --git a/src/migration-scripts/policy/1-to-2 b/src/migration-scripts/policy/1-to-2
old mode 100755
new mode 100644
index c7a983bba..ba3e48db0
--- a/src/migration-scripts/policy/1-to-2
+++ b/src/migration-scripts/policy/1-to-2
@@ -1,86 +1,67 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T4170: rename "policy ipv6-route" to "policy route6" to match common
 #        IPv4/IPv6 schema
 # T4178: Update tcp flags to use multi value node
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['policy']
-config = ConfigTree(config_file)
 
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-if config.exists(base + ['ipv6-route']):
-    config.rename(base + ['ipv6-route'],'route6')
-    config.set_tag(['policy', 'route6'])
+    if config.exists(base + ['ipv6-route']):
+        config.rename(base + ['ipv6-route'],'route6')
+        config.set_tag(['policy', 'route6'])
 
-for route in ['route', 'route6']:
-    if config.exists(base + [route]):
-        for name in config.list_nodes(base + [route]):
-            if config.exists(base + [route, name, 'rule']):
-                for rule in config.list_nodes(base + [route, name, 'rule']):
-                    rule_tcp_flags = base + [route, name, 'rule', rule, 'tcp', 'flags']
+    for route in ['route', 'route6']:
+        if config.exists(base + [route]):
+            for name in config.list_nodes(base + [route]):
+                if config.exists(base + [route, name, 'rule']):
+                    for rule in config.list_nodes(base + [route, name, 'rule']):
+                        rule_tcp_flags = base + [route, name, 'rule', rule, 'tcp', 'flags']
 
-                    if config.exists(rule_tcp_flags):
-                        tmp = config.return_value(rule_tcp_flags)
-                        config.delete(rule_tcp_flags)
-                        for flag in tmp.split(","):
+                        if config.exists(rule_tcp_flags):
+                            tmp = config.return_value(rule_tcp_flags)
+                            config.delete(rule_tcp_flags)
                             for flag in tmp.split(","):
-                                if flag[0] == '!':
-                                    config.set(rule_tcp_flags + ['not', flag[1:].lower()])
-                                else:
-                                    config.set(rule_tcp_flags + [flag.lower()])
+                                for flag in tmp.split(","):
+                                    if flag[0] == '!':
+                                        config.set(rule_tcp_flags + ['not', flag[1:].lower()])
+                                    else:
+                                        config.set(rule_tcp_flags + [flag.lower()])
 
-if config.exists(['interfaces']):
-    def if_policy_rename(config, path):
-        if config.exists(path + ['policy', 'ipv6-route']):
-            config.rename(path + ['policy', 'ipv6-route'], 'route6')
+    if config.exists(['interfaces']):
+        def if_policy_rename(config, path):
+            if config.exists(path + ['policy', 'ipv6-route']):
+                config.rename(path + ['policy', 'ipv6-route'], 'route6')
 
-    for if_type in config.list_nodes(['interfaces']):
-        for ifname in config.list_nodes(['interfaces', if_type]):
-            if_path = ['interfaces', if_type, ifname]
-            if_policy_rename(config, if_path)
+        for if_type in config.list_nodes(['interfaces']):
+            for ifname in config.list_nodes(['interfaces', if_type]):
+                if_path = ['interfaces', if_type, ifname]
+                if_policy_rename(config, if_path)
 
-        for vif_type in ['vif', 'vif-s']:
-            if config.exists(if_path + [vif_type]):
-                for vifname in config.list_nodes(if_path + [vif_type]):
-                    if_policy_rename(config, if_path + [vif_type, vifname])
+            for vif_type in ['vif', 'vif-s']:
+                if config.exists(if_path + [vif_type]):
+                    for vifname in config.list_nodes(if_path + [vif_type]):
+                        if_policy_rename(config, if_path + [vif_type, vifname])
 
-                    if config.exists(if_path + [vif_type, vifname, 'vif-c']):
-                        for vifcname in config.list_nodes(if_path + [vif_type, vifname, 'vif-c']):
-                            if_policy_rename(config, if_path + [vif_type, vifname, 'vif-c', vifcname])
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+                        if config.exists(if_path + [vif_type, vifname, 'vif-c']):
+                            for vifcname in config.list_nodes(if_path + [vif_type, vifname, 'vif-c']):
+                                if_policy_rename(config, if_path + [vif_type, vifname, 'vif-c', vifcname])
diff --git a/src/migration-scripts/policy/2-to-3 b/src/migration-scripts/policy/2-to-3
old mode 100755
new mode 100644
index 8a62c8e6f..399a55387
--- a/src/migration-scripts/policy/2-to-3
+++ b/src/migration-scripts/policy/2-to-3
@@ -1,58 +1,38 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3976: change cli
 #     from: set policy route-map FOO rule 10 match ipv6 nexthop 'h:h:h:h:h:h:h:h'
 #       to: set policy route-map FOO rule 10 match ipv6 nexthop address 'h:h:h:h:h:h:h:h'
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['policy', 'route-map']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-for route_map in config.list_nodes(base):
-    if not config.exists(base + [route_map, 'rule']):
-        continue
-    for rule in config.list_nodes(base + [route_map, 'rule']):
-        base_rule = base + [route_map, 'rule', rule]
-
-        if config.exists(base_rule + ['match', 'ipv6', 'nexthop']):
-            tmp = config.return_value(base_rule + ['match', 'ipv6', 'nexthop'])
-            config.delete(base_rule + ['match', 'ipv6', 'nexthop'])
-            config.set(base_rule + ['match', 'ipv6', 'nexthop', 'address'], value=tmp)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
\ No newline at end of file
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    for route_map in config.list_nodes(base):
+        if not config.exists(base + [route_map, 'rule']):
+            continue
+        for rule in config.list_nodes(base + [route_map, 'rule']):
+            base_rule = base + [route_map, 'rule', rule]
+
+            if config.exists(base_rule + ['match', 'ipv6', 'nexthop']):
+                tmp = config.return_value(base_rule + ['match', 'ipv6', 'nexthop'])
+                config.delete(base_rule + ['match', 'ipv6', 'nexthop'])
+                config.set(base_rule + ['match', 'ipv6', 'nexthop', 'address'], value=tmp)
diff --git a/src/migration-scripts/policy/3-to-4 b/src/migration-scripts/policy/3-to-4
old mode 100755
new mode 100644
index 476fa3af2..5d4959def
--- a/src/migration-scripts/policy/3-to-4
+++ b/src/migration-scripts/policy/3-to-4
@@ -1,162 +1,143 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T4660: change cli
 #     from: set policy route-map FOO rule 10 set community 'TEXT'
 #     Multiple value
 #     to: set policy route-map FOO rule 10 set community replace <community>
 #     Multiple value
 #     to: set policy route-map FOO rule 10 set community add <community>
 #     to: set policy route-map FOO rule 10 set community none
 #
 #     from: set policy route-map FOO rule 10 set large-community 'TEXT'
 #     Multiple value
 #     to: set policy route-map FOO rule 10 set large-community replace <community>
 #     Multiple value
 #     to: set policy route-map FOO rule 10 set large-community add <community>
 #     to: set policy route-map FOO rule 10 set large-community none
 #
 #     from: set policy route-map FOO rule 10 set extecommunity [rt|soo] 'TEXT'
 #     Multiple value
 #     to: set policy route-map FOO rule 10 set extcommunity [rt|soo] <community>
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
 
 # Migration function for large and regular communities
 def community_migrate(config: ConfigTree, rule: list[str]) -> bool:
     """
 
     :param config: configuration object
     :type config: ConfigTree
     :param rule: Path to variable
     :type rule: list[str]
     :return: True if additive presents in community string
     :rtype: bool
     """
     community_list = list((config.return_value(rule)).split(" "))
     config.delete(rule)
     if 'none' in community_list:
         config.set(rule + ['none'])
         return False
     else:
         community_action: str = 'replace'
         if 'additive' in community_list:
             community_action = 'add'
             community_list.remove('additive')
         for community in community_list:
             config.set(rule + [community_action], value=community,
                        replace=False)
         if community_action == 'replace':
             return False
         else:
             return True
 
 
 # Migration function for extcommunities
 def extcommunity_migrate(config: ConfigTree, rule: list[str]) -> None:
     """
 
     :param config: configuration object
     :type config: ConfigTree
     :param rule: Path to variable
     :type rule: list[str]
     """
     # if config.exists(rule + ['bandwidth']):
     #     bandwidth: str = config.return_value(rule + ['bandwidth'])
     #     config.delete(rule + ['bandwidth'])
     #     config.set(rule + ['bandwidth'], value=bandwidth)
 
     if config.exists(rule + ['rt']):
         community_list = list((config.return_value(rule + ['rt'])).split(" "))
         config.delete(rule + ['rt'])
         for community in community_list:
             config.set(rule + ['rt'], value=community, replace=False)
 
     if config.exists(rule + ['soo']):
         community_list = list((config.return_value(rule + ['soo'])).split(" "))
         config.delete(rule + ['soo'])
         for community in community_list:
             config.set(rule + ['soo'], value=community, replace=False)
 
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name: str = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base: list[str] = ['policy', 'route-map']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-for route_map in config.list_nodes(base):
-    if not config.exists(base + [route_map, 'rule']):
-        continue
-    for rule in config.list_nodes(base + [route_map, 'rule']):
-        base_rule: list[str] = base + [route_map, 'rule', rule, 'set']
-
-        # IF additive presents in coummunity then comm-list is redundant
-        isAdditive: bool = True
-        #### Change Set community ########
-        if config.exists(base_rule + ['community']):
-            isAdditive = community_migrate(config,
-                                           base_rule + ['community'])
-
-        #### Change Set community-list delete migrate ########
-        if config.exists(base_rule + ['comm-list', 'comm-list']):
-            if isAdditive:
-                tmp = config.return_value(
-                    base_rule + ['comm-list', 'comm-list'])
-                config.delete(base_rule + ['comm-list'])
-                config.set(base_rule + ['community', 'delete'], value=tmp)
-            else:
-                config.delete(base_rule + ['comm-list'])
-
-        isAdditive = False
-        #### Change Set large-community ########
-        if config.exists(base_rule + ['large-community']):
-            isAdditive = community_migrate(config,
-                                           base_rule + ['large-community'])
-
-        #### Change Set large-community delete by List ########
-        if config.exists(base_rule + ['large-comm-list-delete']):
-            if isAdditive:
-                tmp = config.return_value(
-                    base_rule + ['large-comm-list-delete'])
-                config.delete(base_rule + ['large-comm-list-delete'])
-                config.set(base_rule + ['large-community', 'delete'],
-                           value=tmp)
-            else:
-                config.delete(base_rule + ['large-comm-list-delete'])
-
-        #### Change Set extcommunity ########
-        extcommunity_migrate(config, base_rule + ['extcommunity'])
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    for route_map in config.list_nodes(base):
+        if not config.exists(base + [route_map, 'rule']):
+            continue
+        for rule in config.list_nodes(base + [route_map, 'rule']):
+            base_rule: list[str] = base + [route_map, 'rule', rule, 'set']
+
+            # IF additive presents in coummunity then comm-list is redundant
+            isAdditive: bool = True
+            #### Change Set community ########
+            if config.exists(base_rule + ['community']):
+                isAdditive = community_migrate(config,
+                                               base_rule + ['community'])
+
+            #### Change Set community-list delete migrate ########
+            if config.exists(base_rule + ['comm-list', 'comm-list']):
+                if isAdditive:
+                    tmp = config.return_value(
+                        base_rule + ['comm-list', 'comm-list'])
+                    config.delete(base_rule + ['comm-list'])
+                    config.set(base_rule + ['community', 'delete'], value=tmp)
+                else:
+                    config.delete(base_rule + ['comm-list'])
+
+            isAdditive = False
+            #### Change Set large-community ########
+            if config.exists(base_rule + ['large-community']):
+                isAdditive = community_migrate(config,
+                                               base_rule + ['large-community'])
+
+            #### Change Set large-community delete by List ########
+            if config.exists(base_rule + ['large-comm-list-delete']):
+                if isAdditive:
+                    tmp = config.return_value(
+                        base_rule + ['large-comm-list-delete'])
+                    config.delete(base_rule + ['large-comm-list-delete'])
+                    config.set(base_rule + ['large-community', 'delete'],
+                               value=tmp)
+                else:
+                    config.delete(base_rule + ['large-comm-list-delete'])
+
+            #### Change Set extcommunity ########
+            extcommunity_migrate(config, base_rule + ['extcommunity'])
diff --git a/src/migration-scripts/policy/4-to-5 b/src/migration-scripts/policy/4-to-5
old mode 100755
new mode 100644
index 738850f67..0ecfdfd5e
--- a/src/migration-scripts/policy/4-to-5
+++ b/src/migration-scripts/policy/4-to-5
@@ -1,135 +1,106 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T2199: Migrate interface policy nodes to policy route <name> interface <ifname>
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base4 = ['policy', 'route']
 base6 = ['policy', 'route6']
-config = ConfigTree(config_file)
-
 
 def delete_orphaned_interface_policy(config, iftype, ifname, vif=None, vifs=None, vifc=None):
     """Delete unexpected policy on interfaces in cases when
        policy does not exist but inreface has a policy configuration
        Example T5941:
          set interfaces bonding bond0 vif 995 policy
     """
     if_path = ['interfaces', iftype, ifname]
 
     if vif:
         if_path += ['vif', vif]
     elif vifs:
         if_path += ['vif-s', vifs]
         if vifc:
             if_path += ['vif-c', vifc]
 
     if not config.exists(if_path + ['policy']):
         return
 
     config.delete(if_path + ['policy'])
 
-
-if not config.exists(base4) and not config.exists(base6):
-    # Delete orphaned nodes on interfaces T5941
-    for iftype in config.list_nodes(['interfaces']):
-        for ifname in config.list_nodes(['interfaces', iftype]):
-            delete_orphaned_interface_policy(config, iftype, ifname)
-
-            if config.exists(['interfaces', iftype, ifname, 'vif']):
-                for vif in config.list_nodes(['interfaces', iftype, ifname, 'vif']):
-                    delete_orphaned_interface_policy(config, iftype, ifname, vif=vif)
-
-            if config.exists(['interfaces', iftype, ifname, 'vif-s']):
-                for vifs in config.list_nodes(['interfaces', iftype, ifname, 'vif-s']):
-                    delete_orphaned_interface_policy(config, iftype, ifname, vifs=vifs)
-
-                    if config.exists(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
-                        for vifc in config.list_nodes(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
-                            delete_orphaned_interface_policy(config, iftype, ifname, vifs=vifs, vifc=vifc)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
-
-    # Nothing to do
-    exit(0)
-
 def migrate_interface(config, iftype, ifname, vif=None, vifs=None, vifc=None):
     if_path = ['interfaces', iftype, ifname]
     ifname_full = ifname
 
     if vif:
         if_path += ['vif', vif]
         ifname_full = f'{ifname}.{vif}'
     elif vifs:
         if_path += ['vif-s', vifs]
         ifname_full = f'{ifname}.{vifs}'
         if vifc:
             if_path += ['vif-c', vifc]
             ifname_full = f'{ifname}.{vifs}.{vifc}'
 
     if not config.exists(if_path + ['policy']):
         return
 
     if config.exists(if_path + ['policy', 'route']):
         route_name = config.return_value(if_path + ['policy', 'route'])
         config.set(base4 + [route_name, 'interface'], value=ifname_full, replace=False)
 
     if config.exists(if_path + ['policy', 'route6']):
         route_name = config.return_value(if_path + ['policy', 'route6'])
         config.set(base6 + [route_name, 'interface'], value=ifname_full, replace=False)
 
     config.delete(if_path + ['policy'])
 
-for iftype in config.list_nodes(['interfaces']):
-    for ifname in config.list_nodes(['interfaces', iftype]):
-        migrate_interface(config, iftype, ifname)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base4) and not config.exists(base6):
+        # Delete orphaned nodes on interfaces T5941
+        for iftype in config.list_nodes(['interfaces']):
+            for ifname in config.list_nodes(['interfaces', iftype]):
+                delete_orphaned_interface_policy(config, iftype, ifname)
+
+                if config.exists(['interfaces', iftype, ifname, 'vif']):
+                    for vif in config.list_nodes(['interfaces', iftype, ifname, 'vif']):
+                        delete_orphaned_interface_policy(config, iftype, ifname, vif=vif)
 
-        if config.exists(['interfaces', iftype, ifname, 'vif']):
-            for vif in config.list_nodes(['interfaces', iftype, ifname, 'vif']):
-                migrate_interface(config, iftype, ifname, vif=vif)
+                if config.exists(['interfaces', iftype, ifname, 'vif-s']):
+                    for vifs in config.list_nodes(['interfaces', iftype, ifname, 'vif-s']):
+                        delete_orphaned_interface_policy(config, iftype, ifname, vifs=vifs)
 
-        if config.exists(['interfaces', iftype, ifname, 'vif-s']):
-            for vifs in config.list_nodes(['interfaces', iftype, ifname, 'vif-s']):
-                migrate_interface(config, iftype, ifname, vifs=vifs)
+                        if config.exists(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
+                            for vifc in config.list_nodes(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
+                                delete_orphaned_interface_policy(config, iftype, ifname, vifs=vifs, vifc=vifc)
 
-                if config.exists(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
-                    for vifc in config.list_nodes(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
-                        migrate_interface(config, iftype, ifname, vifs=vifs, vifc=vifc)
+        # Nothing to do
+        return
+
+    for iftype in config.list_nodes(['interfaces']):
+        for ifname in config.list_nodes(['interfaces', iftype]):
+            migrate_interface(config, iftype, ifname)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+            if config.exists(['interfaces', iftype, ifname, 'vif']):
+                for vif in config.list_nodes(['interfaces', iftype, ifname, 'vif']):
+                    migrate_interface(config, iftype, ifname, vif=vif)
+
+            if config.exists(['interfaces', iftype, ifname, 'vif-s']):
+                for vifs in config.list_nodes(['interfaces', iftype, ifname, 'vif-s']):
+                    migrate_interface(config, iftype, ifname, vifs=vifs)
+
+                    if config.exists(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
+                        for vifc in config.list_nodes(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
+                            migrate_interface(config, iftype, ifname, vifs=vifs, vifc=vifc)
diff --git a/src/migration-scripts/policy/5-to-6 b/src/migration-scripts/policy/5-to-6
old mode 100755
new mode 100644
index 86287d578..acba0b4be
--- a/src/migration-scripts/policy/5-to-6
+++ b/src/migration-scripts/policy/5-to-6
@@ -1,62 +1,42 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5165: Migrate policy local-route rule <tag> destination|source
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base4 = ['policy', 'local-route']
 base6 = ['policy', 'local-route6']
-config = ConfigTree(config_file)
-
-if not config.exists(base4) and not config.exists(base6):
-    # Nothing to do
-    exit(0)
-
-# replace 'policy local-route{v6} rule <tag> destination|source <x.x.x.x>'
-#      => 'policy local-route{v6} rule <tag> destination|source address <x.x.x.x>'
-for base in [base4, base6]:
-    if config.exists(base + ['rule']):
-        for rule in config.list_nodes(base + ['rule']):
-            dst_path = base + ['rule', rule, 'destination']
-            src_path = base + ['rule', rule, 'source']
-            # Destination
-            if config.exists(dst_path):
-                for dst_addr in config.return_values(dst_path):
-                    config.set(dst_path + ['address'], value=dst_addr, replace=False)
-            # Source
-            if config.exists(src_path):
-                for src_addr in config.return_values(src_path):
-                    config.set(src_path + ['address'], value=src_addr, replace=False)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base4) and not config.exists(base6):
+        # Nothing to do
+        return
+
+    # replace 'policy local-route{v6} rule <tag> destination|source <x.x.x.x>'
+    #      => 'policy local-route{v6} rule <tag> destination|source address <x.x.x.x>'
+    for base in [base4, base6]:
+        if config.exists(base + ['rule']):
+            for rule in config.list_nodes(base + ['rule']):
+                dst_path = base + ['rule', rule, 'destination']
+                src_path = base + ['rule', rule, 'source']
+                # Destination
+                if config.exists(dst_path):
+                    for dst_addr in config.return_values(dst_path):
+                        config.set(dst_path + ['address'], value=dst_addr, replace=False)
+                # Source
+                if config.exists(src_path):
+                    for src_addr in config.return_values(src_path):
+                        config.set(src_path + ['address'], value=src_addr, replace=False)
diff --git a/src/migration-scripts/policy/6-to-7 b/src/migration-scripts/policy/6-to-7
old mode 100755
new mode 100644
index cdefc6837..69aa703c5
--- a/src/migration-scripts/policy/6-to-7
+++ b/src/migration-scripts/policy/6-to-7
@@ -1,76 +1,56 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5729: Switch to valueless whenever is possible.
 # From
     # set policy [route | route6] ... rule <rule> log enable
     # set policy [route | route6] ... rule <rule> log disable
 # To
     # set policy [route | route6] ... rule <rule> log
     # Remove command if log=disable
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['policy']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-for family in ['route', 'route6']:
-    if config.exists(base + [family]):
-
-        for policy_name in config.list_nodes(base + [family]):
-            if config.exists(base + [family, policy_name, 'rule']):
-                for rule in config.list_nodes(base + [family, policy_name, 'rule']):
-                    # Log
-                    if config.exists(base + [family, policy_name, 'rule', rule, 'log']):
-                        log_value = config.return_value(base + [family, policy_name, 'rule', rule, 'log'])
-                        config.delete(base + [family, policy_name, 'rule', rule, 'log'])
-                        if log_value == 'enable':
-                            config.set(base + [family, policy_name, 'rule', rule, 'log'])
-                    # State
-                    if config.exists(base + [family, policy_name, 'rule', rule, 'state']):
-                        flag_enable = 'False'
-                        for state in ['established', 'invalid', 'new', 'related']:
-                            if config.exists(base + [family, policy_name, 'rule', rule, 'state', state]):
-                                state_value = config.return_value(base + [family, policy_name, 'rule', rule, 'state', state])
-                                config.delete(base + [family, policy_name, 'rule', rule, 'state', state])
-                                if state_value == 'enable':
-                                    config.set(base + [family, policy_name, 'rule', rule, 'state'], value=state, replace=False)
-                                    flag_enable = 'True'
-                        if flag_enable == 'False':
-                            config.delete(base + [family, policy_name, 'rule', rule, 'state'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    for family in ['route', 'route6']:
+        if config.exists(base + [family]):
+
+            for policy_name in config.list_nodes(base + [family]):
+                if config.exists(base + [family, policy_name, 'rule']):
+                    for rule in config.list_nodes(base + [family, policy_name, 'rule']):
+                        # Log
+                        if config.exists(base + [family, policy_name, 'rule', rule, 'log']):
+                            log_value = config.return_value(base + [family, policy_name, 'rule', rule, 'log'])
+                            config.delete(base + [family, policy_name, 'rule', rule, 'log'])
+                            if log_value == 'enable':
+                                config.set(base + [family, policy_name, 'rule', rule, 'log'])
+                        # State
+                        if config.exists(base + [family, policy_name, 'rule', rule, 'state']):
+                            flag_enable = 'False'
+                            for state in ['established', 'invalid', 'new', 'related']:
+                                if config.exists(base + [family, policy_name, 'rule', rule, 'state', state]):
+                                    state_value = config.return_value(base + [family, policy_name, 'rule', rule, 'state', state])
+                                    config.delete(base + [family, policy_name, 'rule', rule, 'state', state])
+                                    if state_value == 'enable':
+                                        config.set(base + [family, policy_name, 'rule', rule, 'state'], value=state, replace=False)
+                                        flag_enable = 'True'
+                            if flag_enable == 'False':
+                                config.delete(base + [family, policy_name, 'rule', rule, 'state'])
diff --git a/src/migration-scripts/policy/7-to-8 b/src/migration-scripts/policy/7-to-8
old mode 100755
new mode 100644
index 73eece1a6..a887f37fe
--- a/src/migration-scripts/policy/7-to-8
+++ b/src/migration-scripts/policy/7-to-8
@@ -1,56 +1,36 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5834: Rename 'enable-default-log' to 'default-log'
 # From
     # set policy [route | route 6] <route> enable-default-log
 # To
     # set policy [route | route 6] <route> default-log
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['policy']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
 
-for family in ['route', 'route6']:
-    if config.exists(base + [family]):
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-        for policy_name in config.list_nodes(base + [family]):
-            if config.exists(base + [family, policy_name, 'enable-default-log']):
-                config.rename(base + [family, policy_name, 'enable-default-log'], 'default-log')
+    for family in ['route', 'route6']:
+        if config.exists(base + [family]):
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+            for policy_name in config.list_nodes(base + [family]):
+                if config.exists(base + [family, policy_name, 'enable-default-log']):
+                    config.rename(base + [family, policy_name, 'enable-default-log'], 'default-log')
diff --git a/src/migration-scripts/pppoe-server/0-to-1 b/src/migration-scripts/pppoe-server/0-to-1
old mode 100755
new mode 100644
index 4d36f8545..8c9a24fbe
--- a/src/migration-scripts/pppoe-server/0-to-1
+++ b/src/migration-scripts/pppoe-server/0-to-1
@@ -1,50 +1,33 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Convert "service pppoe-server authentication radius-server node key"
 # to: "service pppoe-server authentication radius-server node secret"
 
-from sys import argv, exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-ctree = ConfigTree(config_file)
 base = ['service', 'pppoe-server', 'authentication', 'radius-server']
 
-if not ctree.exists(base):
-    # Nothing to do
-    exit(0)
-else:
+def migrate(ctree: ConfigTree) -> None:
+    if not ctree.exists(base):
+        # Nothing to do
+        return
+
     nodes = ctree.list_nodes(base)
     for node in nodes:
         if ctree.exists(base + [node, 'key']):
             val = ctree.return_value(base + [node, 'key'])
             ctree.set(base + [node, 'secret'], value=val, replace=False)
             ctree.delete(base + [node, 'key'])
-
-    try:
-        open(file_name,'w').write(ctree.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/pppoe-server/1-to-2 b/src/migration-scripts/pppoe-server/1-to-2
old mode 100755
new mode 100644
index b266893c0..c9c968bff
--- a/src/migration-scripts/pppoe-server/1-to-2
+++ b/src/migration-scripts/pppoe-server/1-to-2
@@ -1,58 +1,41 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # change mppe node to a leaf node with value prefer
 
-from sys import argv, exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
+base = ['service', 'pppoe-server']
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-base = ['service', 'pppoe-server']
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-else:
     mppe_base = base + ['ppp-options', 'mppe']
     if config.exists(mppe_base):
         # get current values
         tmp = config.list_nodes(mppe_base)
         # drop node(s) first ...
         config.delete(mppe_base)
 
         print(tmp)
         # set new value based on preference
         if 'require' in tmp:
             config.set(mppe_base, value='require')
         elif 'prefer' in tmp:
             config.set(mppe_base, value='prefer')
         elif 'deny' in tmp:
             config.set(mppe_base, value='deny')
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/pppoe-server/2-to-3 b/src/migration-scripts/pppoe-server/2-to-3
old mode 100755
new mode 100644
index a7be060df..160cffdf8
--- a/src/migration-scripts/pppoe-server/2-to-3
+++ b/src/migration-scripts/pppoe-server/2-to-3
@@ -1,48 +1,31 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Convert "service pppoe-server interface ethX" to: "service pppoe-server interface ethX {}"
 
-from sys import argv, exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-ctree = ConfigTree(config_file)
 cbase = ['service', 'pppoe-server','interface']
 
-if not ctree.exists(cbase):
-    exit(0)
-else:
+def migrate(ctree: ConfigTree) -> None:
+    if not ctree.exists(cbase):
+        return
+
     nics = ctree.return_values(cbase)
     # convert leafNode to a tagNode
     ctree.set(cbase)
     ctree.set_tag(cbase)
     for nic in nics:
         ctree.set(cbase + [nic])
-
-    try:
-        open(file_name,'w').write(ctree.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/pppoe-server/3-to-4 b/src/migration-scripts/pppoe-server/3-to-4
old mode 100755
new mode 100644
index 477ed6f22..29dd62201
--- a/src/migration-scripts/pppoe-server/3-to-4
+++ b/src/migration-scripts/pppoe-server/3-to-4
@@ -1,139 +1,121 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - remove primary/secondary identifier from nameserver
 
-from sys import argv, exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['service', 'pppoe-server']
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-else:
+
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
     # Migrate IPv4 DNS servers
     dns_base = base + ['dns-servers']
     if config.exists(dns_base):
         for server in ['server-1', 'server-2']:
           if config.exists(dns_base + [server]):
             dns = config.return_value(dns_base + [server])
             config.set(base + ['name-server'], value=dns, replace=False)
 
         config.delete(dns_base)
 
     # Migrate IPv6 DNS servers
     dns_base = base + ['dnsv6-servers']
     if config.exists(dns_base):
         for server in ['server-1', 'server-2', 'server-3']:
           if config.exists(dns_base + [server]):
             dns = config.return_value(dns_base + [server])
             config.set(base + ['name-server'], value=dns, replace=False)
 
         config.delete(dns_base)
 
     # Migrate IPv4 WINS servers
     wins_base = base + ['wins-servers']
     if config.exists(wins_base):
         for server in ['server-1', 'server-2']:
           if config.exists(wins_base + [server]):
             wins = config.return_value(wins_base + [server])
             config.set(base + ['wins-server'], value=wins, replace=False)
 
         config.delete(wins_base)
 
     # Migrate radius-settings node to RADIUS and use this as base for the
     # later migration of the RADIUS servers - this will save a lot of code
     radius_settings = base + ['authentication', 'radius-settings']
     if config.exists(radius_settings):
         config.rename(radius_settings, 'radius')
 
     # Migrate RADIUS dynamic author / change of authorisation server
     dae_old = base + ['authentication', 'radius', 'dae-server']
     if config.exists(dae_old):
         config.rename(dae_old, 'dynamic-author')
         dae_new = base + ['authentication', 'radius', 'dynamic-author']
 
         if config.exists(dae_new + ['ip-address']):
             config.rename(dae_new + ['ip-address'], 'server')
 
         if config.exists(dae_new + ['secret']):
             config.rename(dae_new + ['secret'], 'key')
 
     # Migrate RADIUS server
     radius_server = base + ['authentication', 'radius-server']
     if config.exists(radius_server):
         new_base = base + ['authentication', 'radius', 'server']
         config.set(new_base)
         config.set_tag(new_base)
         for server in config.list_nodes(radius_server):
             old_base = radius_server + [server]
             config.copy(old_base, new_base + [server])
 
             # migrate key
             if config.exists(new_base + [server, 'secret']):
                 config.rename(new_base + [server, 'secret'], 'key')
 
             # remove old req-limit node
             if config.exists(new_base + [server, 'req-limit']):
                 config.delete(new_base + [server, 'req-limit'])
 
         config.delete(radius_server)
 
     # Migrate IPv6 prefixes
     ipv6_base = base + ['client-ipv6-pool']
     if config.exists(ipv6_base + ['prefix']):
         prefix_old = config.return_values(ipv6_base + ['prefix'])
         # delete old prefix CLI nodes
         config.delete(ipv6_base + ['prefix'])
         # create ned prefix tag node
         config.set(ipv6_base + ['prefix'])
         config.set_tag(ipv6_base + ['prefix'])
 
         for p in prefix_old:
             prefix = p.split(',')[0]
             mask = p.split(',')[1]
             config.set(ipv6_base + ['prefix', prefix, 'mask'], value=mask)
 
     if config.exists(ipv6_base + ['delegate-prefix']):
         prefix_old = config.return_values(ipv6_base + ['delegate-prefix'])
         # delete old delegate prefix CLI nodes
         config.delete(ipv6_base + ['delegate-prefix'])
         # create ned delegation tag node
         config.set(ipv6_base + ['delegate'])
         config.set_tag(ipv6_base + ['delegate'])
 
         for p in prefix_old:
             prefix = p.split(',')[0]
             mask = p.split(',')[1]
             config.set(ipv6_base + ['delegate', prefix, 'delegation-prefix'], value=mask)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/pppoe-server/4-to-5 b/src/migration-scripts/pppoe-server/4-to-5
old mode 100755
new mode 100644
index 5850db673..03fbfb247
--- a/src/migration-scripts/pppoe-server/4-to-5
+++ b/src/migration-scripts/pppoe-server/4-to-5
@@ -1,49 +1,30 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - rename local-ip to gateway-address
 
 from vyos.configtree import ConfigTree
-from sys import argv
-from sys import exit
-
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
 
-file_name = argv[1]
+base_path = ['service', 'pppoe-server']
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base_path):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-base_path = ['service', 'pppoe-server']
-if not config.exists(base_path):
-    # Nothing to do
-    exit(0)
-else:
     config_gw = base_path + ['local-ip']
     if config.exists(config_gw):
         config.rename(config_gw, 'gateway-address')
         config.delete(config_gw)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
-
diff --git a/src/migration-scripts/pppoe-server/5-to-6 b/src/migration-scripts/pppoe-server/5-to-6
old mode 100755
new mode 100644
index e079ae684..13de8f8d2
--- a/src/migration-scripts/pppoe-server/5-to-6
+++ b/src/migration-scripts/pppoe-server/5-to-6
@@ -1,52 +1,33 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - T4703: merge vlan-id and vlan-range to vlan CLI node
 
 from vyos.configtree import ConfigTree
-from sys import argv
-from sys import exit
-
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base_path = ['service', 'pppoe-server', 'interface']
-if not config.exists(base_path):
-    # Nothing to do
-    exit(0)
-
-for interface in config.list_nodes(base_path):
-    for vlan in ['vlan-id', 'vlan-range']:
-        if config.exists(base_path + [interface, vlan]):
-            print(interface, vlan)
-            for tmp in config.return_values(base_path + [interface, vlan]):
-                config.set(base_path + [interface, 'vlan'], value=tmp, replace=False)
-            config.delete(base_path + [interface, vlan])
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
 
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base_path):
+        # Nothing to do
+        return
+
+    for interface in config.list_nodes(base_path):
+        for vlan in ['vlan-id', 'vlan-range']:
+            if config.exists(base_path + [interface, vlan]):
+                print(interface, vlan)
+                for tmp in config.return_values(base_path + [interface, vlan]):
+                    config.set(base_path + [interface, 'vlan'], value=tmp, replace=False)
+                config.delete(base_path + [interface, vlan])
diff --git a/src/migration-scripts/pppoe-server/6-to-7 b/src/migration-scripts/pppoe-server/6-to-7
old mode 100755
new mode 100644
index d51c1c9d8..79745a0c6
--- a/src/migration-scripts/pppoe-server/6-to-7
+++ b/src/migration-scripts/pppoe-server/6-to-7
@@ -1,117 +1,99 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - move all pool to named pools
 #       'start-stop' migrate to namedpool 'default-range-pool'
 #       'subnet' migrate to namedpool 'default-subnet-pool'
 #       'default-subnet-pool' is the next pool for 'default-range-pool'
 # - There is only one gateway-address, take the first which is configured
 # - default-pool by migration.
 #       1. If authentication mode = 'local' then it is first named pool.
 #       If there are not named pools, namedless pool will be default.
 #       2. If authentication mode = 'radius' then namedless pool will be default
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 from vyos.base import Warning
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['service', 'pppoe-server']
 pool_base = base + ['client-ip-pool']
-if not config.exists(base):
-    exit(0)
-
-if not config.exists(pool_base):
-    exit(0)
-
-default_pool = ''
-range_pool_name = 'default-range-pool'
-
-#Default nameless pools migrations
-if config.exists(pool_base + ['start']) and config.exists(pool_base + ['stop']):
-    def is_legalrange(ip1: str, ip2: str, mask: str):
-        from ipaddress import IPv4Interface
-        interface1 = IPv4Interface(f'{ip1}/{mask}')
-        interface2 = IPv4Interface(f'{ip2}/{mask}')
-        return interface1.network.network_address == interface2.network.network_address and interface2.ip > interface1.ip
 
-    start_ip = config.return_value(pool_base + ['start'])
-    stop_ip = config.return_value(pool_base + ['stop'])
-    if is_legalrange(start_ip, stop_ip, '24'):
-        ip_range = f'{start_ip}-{stop_ip}'
-        config.set(pool_base + [range_pool_name, 'range'], value=ip_range, replace=False)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
+
+    if not config.exists(pool_base):
+        return
+
+    default_pool = ''
+    range_pool_name = 'default-range-pool'
+
+    #Default nameless pools migrations
+    if config.exists(pool_base + ['start']) and config.exists(pool_base + ['stop']):
+        def is_legalrange(ip1: str, ip2: str, mask: str):
+            from ipaddress import IPv4Interface
+            interface1 = IPv4Interface(f'{ip1}/{mask}')
+            interface2 = IPv4Interface(f'{ip2}/{mask}')
+            return interface1.network.network_address == interface2.network.network_address and interface2.ip > interface1.ip
+
+        start_ip = config.return_value(pool_base + ['start'])
+        stop_ip = config.return_value(pool_base + ['stop'])
+        if is_legalrange(start_ip, stop_ip, '24'):
+            ip_range = f'{start_ip}-{stop_ip}'
+            config.set(pool_base + [range_pool_name, 'range'], value=ip_range, replace=False)
+            default_pool = range_pool_name
+        else:
+            Warning(
+                f'PPPoE client-ip-pool range start-ip:{start_ip} and stop-ip:{stop_ip} can not be migrated.')
+        config.delete(pool_base + ['start'])
+        config.delete(pool_base + ['stop'])
+
+    if config.exists(pool_base + ['subnet']):
         default_pool = range_pool_name
-    else:
-        Warning(
-            f'PPPoE client-ip-pool range start-ip:{start_ip} and stop-ip:{stop_ip} can not be migrated.')
-    config.delete(pool_base + ['start'])
-    config.delete(pool_base + ['stop'])
-
-if config.exists(pool_base + ['subnet']):
-    default_pool = range_pool_name
-    for subnet in config.return_values(pool_base + ['subnet']):
-        config.set(pool_base + [range_pool_name, 'range'], value=subnet, replace=False)
-    config.delete(pool_base + ['subnet'])
-
-gateway = ''
-if config.exists(base + ['gateway-address']):
-    gateway = config.return_value(base + ['gateway-address'])
-
-#named pool migration
-namedpools_base = pool_base + ['name']
-if config.exists(namedpools_base):
-    if config.exists(base + ['authentication', 'mode']):
-        if config.return_value(base + ['authentication', 'mode']) == 'local':
-            if config.list_nodes(namedpools_base):
-                default_pool = config.list_nodes(namedpools_base)[0]
-
-    for pool_name in config.list_nodes(namedpools_base):
-        pool_path = namedpools_base + [pool_name]
-        if config.exists(pool_path + ['subnet']):
-            subnet = config.return_value(pool_path + ['subnet'])
-            config.set(pool_base + [pool_name, 'range'], value=subnet, replace=False)
-        if config.exists(pool_path + ['next-pool']):
-            next_pool = config.return_value(pool_path + ['next-pool'])
-            config.set(pool_base + [pool_name, 'next-pool'], value=next_pool)
-        if not gateway:
-            if config.exists(pool_path + ['gateway-address']):
-                gateway = config.return_value(pool_path + ['gateway-address'])
-
-    config.delete(namedpools_base)
-
-if gateway:
-    config.set(base + ['gateway-address'], value=gateway)
-if default_pool:
-    config.set(base + ['default-pool'], value=default_pool)
-# format as tag node
-config.set_tag(pool_base)
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+        for subnet in config.return_values(pool_base + ['subnet']):
+            config.set(pool_base + [range_pool_name, 'range'], value=subnet, replace=False)
+        config.delete(pool_base + ['subnet'])
+
+    gateway = ''
+    if config.exists(base + ['gateway-address']):
+        gateway = config.return_value(base + ['gateway-address'])
+
+    #named pool migration
+    namedpools_base = pool_base + ['name']
+    if config.exists(namedpools_base):
+        if config.exists(base + ['authentication', 'mode']):
+            if config.return_value(base + ['authentication', 'mode']) == 'local':
+                if config.list_nodes(namedpools_base):
+                    default_pool = config.list_nodes(namedpools_base)[0]
+
+        for pool_name in config.list_nodes(namedpools_base):
+            pool_path = namedpools_base + [pool_name]
+            if config.exists(pool_path + ['subnet']):
+                subnet = config.return_value(pool_path + ['subnet'])
+                config.set(pool_base + [pool_name, 'range'], value=subnet, replace=False)
+            if config.exists(pool_path + ['next-pool']):
+                next_pool = config.return_value(pool_path + ['next-pool'])
+                config.set(pool_base + [pool_name, 'next-pool'], value=next_pool)
+            if not gateway:
+                if config.exists(pool_path + ['gateway-address']):
+                    gateway = config.return_value(pool_path + ['gateway-address'])
+
+        config.delete(namedpools_base)
+
+    if gateway:
+        config.set(base + ['gateway-address'], value=gateway)
+    if default_pool:
+        config.set(base + ['default-pool'], value=default_pool)
+    # format as tag node
+    config.set_tag(pool_base)
diff --git a/src/migration-scripts/pppoe-server/7-to-8 b/src/migration-scripts/pppoe-server/7-to-8
old mode 100755
new mode 100644
index 0381f0bf9..90e4fa053
--- a/src/migration-scripts/pppoe-server/7-to-8
+++ b/src/migration-scripts/pppoe-server/7-to-8
@@ -1,58 +1,40 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Migrating to named ipv6 pools
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
+base = ['service', 'pppoe-server']
+pool_base = base + ['client-ipv6-pool']
 
-file_name = argv[1]
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+    if not config.exists(pool_base):
+        return
 
-config = ConfigTree(config_file)
-base = ['service', 'pppoe-server']
-pool_base = base + ['client-ipv6-pool']
-if not config.exists(base):
-    exit(0)
-
-if not config.exists(pool_base):
-    exit(0)
-
-ipv6_pool_name = 'ipv6-pool'
-config.copy(pool_base, pool_base + [ipv6_pool_name])
-
-if config.exists(pool_base + ['prefix']):
-    config.delete(pool_base + ['prefix'])
-    config.set(base + ['default-ipv6-pool'], value=ipv6_pool_name)
-if config.exists(pool_base + ['delegate']):
-    config.delete(pool_base + ['delegate'])
-
-# format as tag node
-config.set_tag(pool_base)
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    ipv6_pool_name = 'ipv6-pool'
+    config.copy(pool_base, pool_base + [ipv6_pool_name])
+
+    if config.exists(pool_base + ['prefix']):
+        config.delete(pool_base + ['prefix'])
+        config.set(base + ['default-ipv6-pool'], value=ipv6_pool_name)
+    if config.exists(pool_base + ['delegate']):
+        config.delete(pool_base + ['delegate'])
+
+    # format as tag node
+    config.set_tag(pool_base)
diff --git a/src/migration-scripts/pppoe-server/8-to-9 b/src/migration-scripts/pppoe-server/8-to-9
old mode 100755
new mode 100644
index 4932a766f..e7e0aaa2c
--- a/src/migration-scripts/pppoe-server/8-to-9
+++ b/src/migration-scripts/pppoe-server/8-to-9
@@ -1,66 +1,48 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Change from 'ccp' to 'disable-ccp' in ppp-option section
 # Migration ipv6 options
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['service', 'pppoe-server']
-if not config.exists(base):
-    exit(0)
-
-#CCP migration
-if config.exists(base + ['ppp-options', 'ccp']):
-    config.delete(base + ['ppp-options', 'ccp'])
-else:
-    config.set(base + ['ppp-options', 'disable-ccp'])
-
-#IPV6 options migrations
-if config.exists(base + ['ppp-options','ipv6-peer-intf-id']):
-    intf_peer_id = config.return_value(base + ['ppp-options','ipv6-peer-intf-id'])
-    if intf_peer_id == 'ipv4':
-        intf_peer_id = 'ipv4-addr'
-    config.set(base + ['ppp-options','ipv6-peer-interface-id'], value=intf_peer_id, replace=True)
-    config.delete(base + ['ppp-options','ipv6-peer-intf-id'])
-
-if config.exists(base + ['ppp-options','ipv6-intf-id']):
-    intf_id = config.return_value(base + ['ppp-options','ipv6-intf-id'])
-    config.set(base + ['ppp-options','ipv6-interface-id'], value=intf_id, replace=True)
-    config.delete(base + ['ppp-options','ipv6-intf-id'])
-
-if config.exists(base + ['ppp-options','ipv6-accept-peer-intf-id']):
-    config.set(base + ['ppp-options','ipv6-accept-peer-interface-id'])
-    config.delete(base + ['ppp-options','ipv6-accept-peer-intf-id'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
+
+    #CCP migration
+    if config.exists(base + ['ppp-options', 'ccp']):
+        config.delete(base + ['ppp-options', 'ccp'])
+    else:
+        config.set(base + ['ppp-options', 'disable-ccp'])
+
+    #IPV6 options migrations
+    if config.exists(base + ['ppp-options','ipv6-peer-intf-id']):
+        intf_peer_id = config.return_value(base + ['ppp-options','ipv6-peer-intf-id'])
+        if intf_peer_id == 'ipv4':
+            intf_peer_id = 'ipv4-addr'
+        config.set(base + ['ppp-options','ipv6-peer-interface-id'], value=intf_peer_id, replace=True)
+        config.delete(base + ['ppp-options','ipv6-peer-intf-id'])
+
+    if config.exists(base + ['ppp-options','ipv6-intf-id']):
+        intf_id = config.return_value(base + ['ppp-options','ipv6-intf-id'])
+        config.set(base + ['ppp-options','ipv6-interface-id'], value=intf_id, replace=True)
+        config.delete(base + ['ppp-options','ipv6-intf-id'])
+
+    if config.exists(base + ['ppp-options','ipv6-accept-peer-intf-id']):
+        config.set(base + ['ppp-options','ipv6-accept-peer-interface-id'])
+        config.delete(base + ['ppp-options','ipv6-accept-peer-intf-id'])
diff --git a/src/migration-scripts/pppoe-server/9-to-10 b/src/migration-scripts/pppoe-server/9-to-10
old mode 100755
new mode 100644
index e0c782f04..d3475e8ff
--- a/src/migration-scripts/pppoe-server/9-to-10
+++ b/src/migration-scripts/pppoe-server/9-to-10
@@ -1,56 +1,38 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Migration of pado-delay options
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['service', 'pppoe-server', 'pado-delay']
-if not config.exists(base):
-    exit(0)
 
-pado_delay = {}
-for delay in config.list_nodes(base):
-    sessions = config.return_value(base + [delay, 'sessions'])
-    pado_delay[delay] = sessions
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
 
-# need to define delay for latest sessions
-sorted_delays = dict(sorted(pado_delay.items(), key=lambda k_v: int(k_v[1])))
-last_delay = list(sorted_delays)[-1]
+    pado_delay = {}
+    for delay in config.list_nodes(base):
+        sessions = config.return_value(base + [delay, 'sessions'])
+        pado_delay[delay] = sessions
 
-# Rename last delay -> disable
-tmp = base + [last_delay]
-if config.exists(tmp):
-    config.rename(tmp, 'disable')
+    # need to define delay for latest sessions
+    sorted_delays = dict(sorted(pado_delay.items(), key=lambda k_v: int(k_v[1])))
+    last_delay = list(sorted_delays)[-1]
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    # Rename last delay -> disable
+    tmp = base + [last_delay]
+    if config.exists(tmp):
+        config.rename(tmp, 'disable')
diff --git a/src/migration-scripts/pptp/0-to-1 b/src/migration-scripts/pptp/0-to-1
old mode 100755
new mode 100644
index 1b7697c11..dd0b6f57e
--- a/src/migration-scripts/pptp/0-to-1
+++ b/src/migration-scripts/pptp/0-to-1
@@ -1,59 +1,54 @@
-#!/usr/bin/env python3
+# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Unclutter PPTP VPN configuiration - move radius-server top level tag
 # nodes to a regular node which now also configures the radius source address
 # used when querying a radius server
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
+cfg_base = ['vpn', 'pptp', 'remote-access', 'authentication']
 
-config = ConfigTree(config_file)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(cfg_base):
+        # Nothing to do
+        return
 
-cfg_base = ['vpn', 'pptp', 'remote-access', 'authentication']
-if not config.exists(cfg_base):
-    # Nothing to do
-    sys.exit(0)
-else:
     # Migrate "vpn pptp authentication radius-source-address" to new
     # "vpn pptp authentication radius source-address"
     if config.exists(cfg_base + ['radius-source-address']):
         address = config.return_value(cfg_base + ['radius-source-address'])
         # delete old configuration node
         config.delete(cfg_base + ['radius-source-address'])
         # write new configuration node
         config.set(cfg_base + ['radius', 'source-address'], value=address)
 
     # Migrate "vpn pptp authentication radius-server" tag node to new
     # "vpn pptp authentication radius server" tag node
     for server in config.list_nodes(cfg_base + ['radius-server']):
         base_server = cfg_base + ['radius-server', server]
         key = config.return_value(base_server + ['key'])
 
         # delete old configuration node
         config.delete(base_server)
         # write new configuration node
         config.set(cfg_base + ['radius', 'server', server, 'key'], value=key)
 
         # format as tag node
         config.set_tag(cfg_base + ['radius', 'server'])
 
     # delete top level tag node
     if config.exists(cfg_base + ['radius-server']):
         config.delete(cfg_base + ['radius-server'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/pptp/1-to-2 b/src/migration-scripts/pptp/1-to-2
old mode 100755
new mode 100644
index 99624dceb..1e7601193
--- a/src/migration-scripts/pptp/1-to-2
+++ b/src/migration-scripts/pptp/1-to-2
@@ -1,71 +1,53 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - migrate dns-servers node to common name-servers
 # - remove radios req-limit node
 
-from sys import argv, exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
+base = ['vpn', 'pptp', 'remote-access']
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-base = ['vpn', 'pptp', 'remote-access']
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-else:
     # Migrate IPv4 DNS servers
     dns_base = base + ['dns-servers']
     if config.exists(dns_base):
         for server in ['server-1', 'server-2']:
           if config.exists(dns_base + [server]):
             dns = config.return_value(dns_base + [server])
             config.set(base + ['name-server'], value=dns, replace=False)
 
         config.delete(dns_base)
 
     # Migrate IPv4 WINS servers
     wins_base = base + ['wins-servers']
     if config.exists(wins_base):
         for server in ['server-1', 'server-2']:
           if config.exists(wins_base + [server]):
             wins = config.return_value(wins_base + [server])
             config.set(base + ['wins-server'], value=wins, replace=False)
 
         config.delete(wins_base)
 
     # Remove RADIUS server req-limit node
     radius_base = base + ['authentication', 'radius']
     if config.exists(radius_base):
         for server in config.list_nodes(radius_base + ['server']):
             if config.exists(radius_base + ['server', server, 'req-limit']):
                 config.delete(radius_base + ['server', server, 'req-limit'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/pptp/2-to-3 b/src/migration-scripts/pptp/2-to-3
old mode 100755
new mode 100644
index 42c4dedf4..8b0d6d865
--- a/src/migration-scripts/pptp/2-to-3
+++ b/src/migration-scripts/pptp/2-to-3
@@ -1,73 +1,55 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - move all pool to named pools
 #       'start-stop' migrate to namedpool 'default-range-pool'
 #       'default-subnet-pool' is the next pool for 'default-range-pool'
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 from vyos.base import Warning
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['vpn', 'pptp', 'remote-access']
 pool_base = base + ['client-ip-pool']
-if not config.exists(base):
-    exit(0)
-
-if not config.exists(pool_base):
-    exit(0)
-
-range_pool_name = 'default-range-pool'
-
-if config.exists(pool_base + ['start']) and config.exists(pool_base + ['stop']):
-    def is_legalrange(ip1: str, ip2: str, mask: str):
-        from ipaddress import IPv4Interface
-        interface1 = IPv4Interface(f'{ip1}/{mask}')
-        interface2 = IPv4Interface(f'{ip2}/{mask}')
-        return interface1.network.network_address == interface2.network.network_address and interface2.ip > interface1.ip
-
-    start_ip = config.return_value(pool_base + ['start'])
-    stop_ip = config.return_value(pool_base + ['stop'])
-    if is_legalrange(start_ip, stop_ip, '24'):
-        ip_range = f'{start_ip}-{stop_ip}'
-        config.set(pool_base + [range_pool_name, 'range'], value=ip_range, replace=False)
-        config.set(base + ['default-pool'], value=range_pool_name)
-    else:
-        Warning(
-            f'PPTP client-ip-pool range start-ip:{start_ip} and stop-ip:{stop_ip} can not be migrated.')
-
-    config.delete(pool_base + ['start'])
-    config.delete(pool_base + ['stop'])
-# format as tag node
-config.set_tag(pool_base)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
+
+    if not config.exists(pool_base):
+        return
+
+    range_pool_name = 'default-range-pool'
+
+    if config.exists(pool_base + ['start']) and config.exists(pool_base + ['stop']):
+        def is_legalrange(ip1: str, ip2: str, mask: str):
+            from ipaddress import IPv4Interface
+            interface1 = IPv4Interface(f'{ip1}/{mask}')
+            interface2 = IPv4Interface(f'{ip2}/{mask}')
+            return interface1.network.network_address == interface2.network.network_address and interface2.ip > interface1.ip
+
+        start_ip = config.return_value(pool_base + ['start'])
+        stop_ip = config.return_value(pool_base + ['stop'])
+        if is_legalrange(start_ip, stop_ip, '24'):
+            ip_range = f'{start_ip}-{stop_ip}'
+            config.set(pool_base + [range_pool_name, 'range'], value=ip_range, replace=False)
+            config.set(base + ['default-pool'], value=range_pool_name)
+        else:
+            Warning(
+                f'PPTP client-ip-pool range start-ip:{start_ip} and stop-ip:{stop_ip} can not be migrated.')
+
+        config.delete(pool_base + ['start'])
+        config.delete(pool_base + ['stop'])
+    # format as tag node
+    config.set_tag(pool_base)
diff --git a/src/migration-scripts/pptp/3-to-4 b/src/migration-scripts/pptp/3-to-4
old mode 100755
new mode 100644
index ebd343028..2dabd8475
--- a/src/migration-scripts/pptp/3-to-4
+++ b/src/migration-scripts/pptp/3-to-4
@@ -1,48 +1,29 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - Move 'mppe' from 'authentication' node to 'ppp-options'
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['vpn', 'pptp', 'remote-access']
 
-if not config.exists(base):
-    exit(0)
-
-if config.exists(base + ['authentication','mppe']):
-    mppe = config.return_value(base + ['authentication','mppe'])
-    config.set(base + ['ppp-options', 'mppe'], value=mppe, replace=True)
-    config.delete(base + ['authentication','mppe'])
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    if config.exists(base + ['authentication','mppe']):
+        mppe = config.return_value(base + ['authentication','mppe'])
+        config.set(base + ['ppp-options', 'mppe'], value=mppe, replace=True)
+        config.delete(base + ['authentication','mppe'])
diff --git a/src/migration-scripts/pptp/4-to-5 b/src/migration-scripts/pptp/4-to-5
old mode 100755
new mode 100644
index 83632b6d8..c906f58c4
--- a/src/migration-scripts/pptp/4-to-5
+++ b/src/migration-scripts/pptp/4-to-5
@@ -1,63 +1,43 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - Move 'require' from 'protocols' in  'authentication' node
 # - Migrate to new default values in radius timeout and acct-timeout
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['vpn', 'pptp', 'remote-access']
 
-if not config.exists(base):
-    exit(0)
-
-#migrate require to protocols
-require_path = base + ['authentication', 'require']
-if config.exists(require_path):
-    protocols = list(config.return_values(require_path))
-    for protocol in protocols:
-        config.set(base + ['authentication', 'protocols'], value=protocol,
-                   replace=False)
-    config.delete(require_path)
-else:
-    config.set(base + ['authentication', 'protocols'], value='mschap-v2')
-
-radius_path = base + ['authentication', 'radius']
-if config.exists(radius_path):
-    if not config.exists(radius_path + ['timeout']):
-        config.set(radius_path + ['timeout'], value=3)
-    if not config.exists(radius_path + ['acct-timeout']):
-        config.set(radius_path + ['acct-timeout'], value=3)
-
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
+
+    #migrate require to protocols
+    require_path = base + ['authentication', 'require']
+    if config.exists(require_path):
+        protocols = list(config.return_values(require_path))
+        for protocol in protocols:
+            config.set(base + ['authentication', 'protocols'], value=protocol,
+                       replace=False)
+        config.delete(require_path)
+    else:
+        config.set(base + ['authentication', 'protocols'], value='mschap-v2')
+
+    radius_path = base + ['authentication', 'radius']
+    if config.exists(radius_path):
+        if not config.exists(radius_path + ['timeout']):
+            config.set(radius_path + ['timeout'], value=3)
+        if not config.exists(radius_path + ['acct-timeout']):
+            config.set(radius_path + ['acct-timeout'], value=3)
diff --git a/src/migration-scripts/qos/1-to-2 b/src/migration-scripts/qos/1-to-2
old mode 100755
new mode 100644
index 666811e5a..c43d8fa47
--- a/src/migration-scripts/qos/1-to-2
+++ b/src/migration-scripts/qos/1-to-2
@@ -1,194 +1,168 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sys import argv,exit
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 from vyos.base import Warning
 from vyos.configtree import ConfigTree
 from vyos.utils.file import read_file
 
 def bandwidth_percent_to_val(interface, percent) -> int:
     speed = read_file(f'/sys/class/net/{interface}/speed')
     if not speed.isnumeric():
         Warning('Interface speed cannot be determined (assuming 10 Mbit/s)')
         speed = 10
     speed = int(speed) *1000000 # convert to MBit/s
     return speed * int(percent) // 100 # integer division
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-base = ['traffic-policy']
-config = ConfigTree(config_file)
-
 
 def delete_orphaned_interface_policy(config, iftype, ifname, vif=None, vifs=None, vifc=None):
     """Delete unexpected traffic-policy on interfaces in cases when
        policy does not exist but inreface has a policy configuration
        Example T5941:
          set interfaces bonding bond0 vif 995 traffic-policy
     """
     if_path = ['interfaces', iftype, ifname]
 
     if vif:
         if_path += ['vif', vif]
     elif vifs:
         if_path += ['vif-s', vifs]
         if vifc:
             if_path += ['vif-c', vifc]
 
     if not config.exists(if_path + ['traffic-policy']):
         return
 
     config.delete(if_path + ['traffic-policy'])
 
 
-if not config.exists(base):
-    # Delete orphaned nodes on interfaces T5941
-    for iftype in config.list_nodes(['interfaces']):
-        for ifname in config.list_nodes(['interfaces', iftype]):
-            delete_orphaned_interface_policy(config, iftype, ifname)
-
-            if config.exists(['interfaces', iftype, ifname, 'vif']):
-                for vif in config.list_nodes(['interfaces', iftype, ifname, 'vif']):
-                    delete_orphaned_interface_policy(config, iftype, ifname, vif=vif)
-
-            if config.exists(['interfaces', iftype, ifname, 'vif-s']):
-                for vifs in config.list_nodes(['interfaces', iftype, ifname, 'vif-s']):
-                    delete_orphaned_interface_policy(config, iftype, ifname, vifs=vifs)
-
-                    if config.exists(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
-                        for vifc in config.list_nodes(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
-                            delete_orphaned_interface_policy(config, iftype, ifname, vifs=vifs, vifc=vifc)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
-
-    # Nothing to do
-    exit(0)
-
-iface_config = {}
-
-if config.exists(['interfaces']):
-    def get_qos(config, interface, interface_base):
-        if config.exists(interface_base):
-            tmp = { interface : {} }
-            if config.exists(interface_base + ['in']):
-                tmp[interface]['ingress'] = config.return_value(interface_base + ['in'])
-            if config.exists(interface_base + ['out']):
-                tmp[interface]['egress'] = config.return_value(interface_base + ['out'])
-            config.delete(interface_base)
-            return tmp
-        return None
-
-    # Migrate "interface ethernet eth0 traffic-policy in|out" to "qos interface eth0 ingress|egress"
-    for type in config.list_nodes(['interfaces']):
-        for interface in config.list_nodes(['interfaces', type]):
-            interface_base = ['interfaces', type, interface, 'traffic-policy']
-            tmp = get_qos(config, interface, interface_base)
-            if tmp: iface_config.update(tmp)
-
-            vif_path = ['interfaces', type, interface, 'vif']
-            if config.exists(vif_path):
-                for vif in config.list_nodes(vif_path):
-                    vif_interface_base = vif_path + [vif, 'traffic-policy']
-                    ifname = f'{interface}.{vif}'
-                    tmp = get_qos(config, ifname, vif_interface_base)
-                    if tmp: iface_config.update(tmp)
-
-            vif_s_path = ['interfaces', type, interface, 'vif-s']
-            if config.exists(vif_s_path):
-                for vif_s in config.list_nodes(vif_s_path):
-                    vif_s_interface_base = vif_s_path + [vif_s, 'traffic-policy']
-                    ifname = f'{interface}.{vif_s}'
-                    tmp = get_qos(config, ifname, vif_s_interface_base)
-                    if tmp: iface_config.update(tmp)
-
-                    # vif-c interfaces MUST be migrated before their parent vif-s
-                    # interface as the migrate_*() functions delete the path!
-                    vif_c_path = ['interfaces', type, interface, 'vif-s', vif_s, 'vif-c']
-                    if config.exists(vif_c_path):
-                        for vif_c in config.list_nodes(vif_c_path):
-                            vif_c_interface_base = vif_c_path + [vif_c, 'traffic-policy']
-                            ifname = f'{interface}.{vif_s}.{vif_c}'
-                            tmp = get_qos(config, ifname, vif_s_interface_base)
-                            if tmp: iface_config.update(tmp)
-
-
-# Now we have the information which interface uses which QoS policy.
-# Interface binding will be moved to the qos CLi tree
-config.set(['qos'])
-config.copy(base, ['qos', 'policy'])
-config.delete(base)
-
-# Now map the interface policy binding to the new CLI syntax
-if len(iface_config):
-    config.set(['qos', 'interface'])
-    config.set_tag(['qos', 'interface'])
-
-for interface, interface_config in iface_config.items():
-    config.set(['qos', 'interface', interface])
-    config.set_tag(['qos', 'interface', interface])
-    if 'ingress' in interface_config:
-        config.set(['qos', 'interface', interface, 'ingress'], value=interface_config['ingress'])
-    if 'egress' in interface_config:
-        config.set(['qos', 'interface', interface, 'egress'], value=interface_config['egress'])
-
-# Remove "burst" CLI node from network emulator
-netem_base = ['qos', 'policy', 'network-emulator']
-if config.exists(netem_base):
-    for policy_name in config.list_nodes(netem_base):
-        if config.exists(netem_base + [policy_name, 'burst']):
-            config.delete(netem_base + [policy_name, 'burst'])
-
-# Change bandwidth unit MBit -> mbit as tc only supports mbit
-base = ['qos', 'policy']
-if config.exists(base):
-    for policy_type in config.list_nodes(base):
-        for policy in config.list_nodes(base + [policy_type]):
-            policy_base = base + [policy_type, policy]
-            if config.exists(policy_base + ['bandwidth']):
-                tmp = config.return_value(policy_base + ['bandwidth'])
-                config.set(policy_base + ['bandwidth'], value=tmp.lower())
-
-            if config.exists(policy_base + ['class']):
-                for cls in config.list_nodes(policy_base + ['class']):
-                    cls_base = policy_base + ['class', cls]
-                    if config.exists(cls_base + ['bandwidth']):
-                        tmp = config.return_value(cls_base + ['bandwidth'])
-                        config.set(cls_base + ['bandwidth'], value=tmp.lower())
-
-            if config.exists(policy_base + ['default', 'bandwidth']):
+def migrate(config: ConfigTree) -> None:
+    base = ['traffic-policy']
+
+    if not config.exists(base):
+        # Delete orphaned nodes on interfaces T5941
+        for iftype in config.list_nodes(['interfaces']):
+            for ifname in config.list_nodes(['interfaces', iftype]):
+                delete_orphaned_interface_policy(config, iftype, ifname)
+
+                if config.exists(['interfaces', iftype, ifname, 'vif']):
+                    for vif in config.list_nodes(['interfaces', iftype, ifname, 'vif']):
+                        delete_orphaned_interface_policy(config, iftype, ifname, vif=vif)
+
+                if config.exists(['interfaces', iftype, ifname, 'vif-s']):
+                    for vifs in config.list_nodes(['interfaces', iftype, ifname, 'vif-s']):
+                        delete_orphaned_interface_policy(config, iftype, ifname, vifs=vifs)
+
+                        if config.exists(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
+                            for vifc in config.list_nodes(['interfaces', iftype, ifname, 'vif-s', vifs, 'vif-c']):
+                                delete_orphaned_interface_policy(config, iftype, ifname, vifs=vifs, vifc=vifc)
+
+        # Nothing to do
+        return
+
+    iface_config = {}
+
+    if config.exists(['interfaces']):
+        def get_qos(config, interface, interface_base):
+            if config.exists(interface_base):
+                tmp = { interface : {} }
+                if config.exists(interface_base + ['in']):
+                    tmp[interface]['ingress'] = config.return_value(interface_base + ['in'])
+                if config.exists(interface_base + ['out']):
+                    tmp[interface]['egress'] = config.return_value(interface_base + ['out'])
+                config.delete(interface_base)
+                return tmp
+            return None
+
+        # Migrate "interface ethernet eth0 traffic-policy in|out" to "qos interface eth0 ingress|egress"
+        for type in config.list_nodes(['interfaces']):
+            for interface in config.list_nodes(['interfaces', type]):
+                interface_base = ['interfaces', type, interface, 'traffic-policy']
+                tmp = get_qos(config, interface, interface_base)
+                if tmp: iface_config.update(tmp)
+
+                vif_path = ['interfaces', type, interface, 'vif']
+                if config.exists(vif_path):
+                    for vif in config.list_nodes(vif_path):
+                        vif_interface_base = vif_path + [vif, 'traffic-policy']
+                        ifname = f'{interface}.{vif}'
+                        tmp = get_qos(config, ifname, vif_interface_base)
+                        if tmp: iface_config.update(tmp)
+
+                vif_s_path = ['interfaces', type, interface, 'vif-s']
+                if config.exists(vif_s_path):
+                    for vif_s in config.list_nodes(vif_s_path):
+                        vif_s_interface_base = vif_s_path + [vif_s, 'traffic-policy']
+                        ifname = f'{interface}.{vif_s}'
+                        tmp = get_qos(config, ifname, vif_s_interface_base)
+                        if tmp: iface_config.update(tmp)
+
+                        # vif-c interfaces MUST be migrated before their parent vif-s
+                        # interface as the migrate_*() functions delete the path!
+                        vif_c_path = ['interfaces', type, interface, 'vif-s', vif_s, 'vif-c']
+                        if config.exists(vif_c_path):
+                            for vif_c in config.list_nodes(vif_c_path):
+                                vif_c_interface_base = vif_c_path + [vif_c, 'traffic-policy']
+                                ifname = f'{interface}.{vif_s}.{vif_c}'
+                                tmp = get_qos(config, ifname, vif_s_interface_base)
+                                if tmp: iface_config.update(tmp)
+
+
+    # Now we have the information which interface uses which QoS policy.
+    # Interface binding will be moved to the qos CLi tree
+    config.set(['qos'])
+    config.copy(base, ['qos', 'policy'])
+    config.delete(base)
+
+    # Now map the interface policy binding to the new CLI syntax
+    if len(iface_config):
+        config.set(['qos', 'interface'])
+        config.set_tag(['qos', 'interface'])
+
+    for interface, interface_config in iface_config.items():
+        config.set(['qos', 'interface', interface])
+        config.set_tag(['qos', 'interface', interface])
+        if 'ingress' in interface_config:
+            config.set(['qos', 'interface', interface, 'ingress'], value=interface_config['ingress'])
+        if 'egress' in interface_config:
+            config.set(['qos', 'interface', interface, 'egress'], value=interface_config['egress'])
+
+    # Remove "burst" CLI node from network emulator
+    netem_base = ['qos', 'policy', 'network-emulator']
+    if config.exists(netem_base):
+        for policy_name in config.list_nodes(netem_base):
+            if config.exists(netem_base + [policy_name, 'burst']):
+                config.delete(netem_base + [policy_name, 'burst'])
+
+    # Change bandwidth unit MBit -> mbit as tc only supports mbit
+    base = ['qos', 'policy']
+    if config.exists(base):
+        for policy_type in config.list_nodes(base):
+            for policy in config.list_nodes(base + [policy_type]):
+                policy_base = base + [policy_type, policy]
+                if config.exists(policy_base + ['bandwidth']):
+                    tmp = config.return_value(policy_base + ['bandwidth'])
+                    config.set(policy_base + ['bandwidth'], value=tmp.lower())
+
+                if config.exists(policy_base + ['class']):
+                    for cls in config.list_nodes(policy_base + ['class']):
+                        cls_base = policy_base + ['class', cls]
+                        if config.exists(cls_base + ['bandwidth']):
+                            tmp = config.return_value(cls_base + ['bandwidth'])
+                            config.set(cls_base + ['bandwidth'], value=tmp.lower())
+
                 if config.exists(policy_base + ['default', 'bandwidth']):
-                    tmp = config.return_value(policy_base + ['default', 'bandwidth'])
-                    config.set(policy_base + ['default', 'bandwidth'], value=tmp.lower())
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+                    if config.exists(policy_base + ['default', 'bandwidth']):
+                        tmp = config.return_value(policy_base + ['default', 'bandwidth'])
+                        config.set(policy_base + ['default', 'bandwidth'], value=tmp.lower())
diff --git a/src/migration-scripts/quagga/10-to-11 b/src/migration-scripts/quagga/10-to-11
old mode 100755
new mode 100644
index 0ed4f5df6..15dbbb193
--- a/src/migration-scripts/quagga/10-to-11
+++ b/src/migration-scripts/quagga/10-to-11
@@ -1,51 +1,31 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5150: Rework CLI definitions to apply route-maps between routing daemons
 #        and zebra/kernel
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 static_base = ['protocols', 'static']
-# Check if static routes are configured - if so, migrate the CLI node
-if config.exists(static_base):
-    if config.exists(static_base + ['route-map']):
-        tmp = config.return_value(static_base + ['route-map'])
 
-        config.set(['system', 'ip', 'protocol', 'static', 'route-map'], value=tmp)
-        config.set_tag(['system', 'ip', 'protocol'])
-        config.delete(static_base + ['route-map'])
+def migrate(config: ConfigTree) -> None:
+    # Check if static routes are configured - if so, migrate the CLI node
+    if config.exists(static_base):
+        if config.exists(static_base + ['route-map']):
+            tmp = config.return_value(static_base + ['route-map'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+            config.set(['system', 'ip', 'protocol', 'static', 'route-map'], value=tmp)
+            config.set_tag(['system', 'ip', 'protocol'])
+            config.delete(static_base + ['route-map'])
diff --git a/src/migration-scripts/quagga/2-to-3 b/src/migration-scripts/quagga/2-to-3
old mode 100755
new mode 100644
index 96b56da70..d62c387ba
--- a/src/migration-scripts/quagga/2-to-3
+++ b/src/migration-scripts/quagga/2-to-3
@@ -1,203 +1,181 @@
-#!/usr/bin/env python3
+# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2018 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-#
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-
-import sys
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 from vyos.configtree import ConfigTree
 
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 def migrate_neighbor(config, neighbor_path, neighbor):
     if config.exists(neighbor_path):
         neighbors = config.list_nodes(neighbor_path)
         for neighbor in neighbors:
             # Move the valueless options: as-override, next-hop-self, route-reflector-client, route-server-client,
             # remove-private-as
             for valueless_option in ['as-override', 'nexthop-self', 'route-reflector-client', 'route-server-client',
                                      'remove-private-as']:
                 if config.exists(neighbor_path + [neighbor, valueless_option]):
                     config.set(neighbor_path + [neighbor] + af_path + [valueless_option])
                     config.delete(neighbor_path + [neighbor, valueless_option])
 
             # Move filter options: distribute-list, filter-list, prefix-list, and route-map
             # They share the same syntax inside so we can group them
             for filter_type in ['distribute-list', 'filter-list', 'prefix-list', 'route-map']:
                 if config.exists(neighbor_path + [neighbor, filter_type]):
                     for filter_dir in ['import', 'export']:
                         if config.exists(neighbor_path + [neighbor, filter_type, filter_dir]):
                             filter_name = config.return_value(neighbor_path + [neighbor, filter_type, filter_dir])
                             config.set(neighbor_path + [neighbor] + af_path + [filter_type, filter_dir], value=filter_name)
                     config.delete(neighbor_path + [neighbor, filter_type])
 
             # Move simple leaf node options: maximum-prefix, unsuppress-map, weight
             for leaf_option in ['maximum-prefix', 'unsuppress-map', 'weight']:
                 if config.exists(neighbor_path + [neighbor, leaf_option]):
                     if config.exists(neighbor_path + [neighbor, leaf_option]):
                         leaf_opt_value = config.return_value(neighbor_path + [neighbor, leaf_option])
                         config.set(neighbor_path + [neighbor] + af_path + [leaf_option], value=leaf_opt_value)
                         config.delete(neighbor_path + [neighbor, leaf_option])
 
             # The rest is special cases, for better or worse
 
             # Move allowas-in
             if config.exists(neighbor_path + [neighbor, 'allowas-in']):
                 if config.exists(neighbor_path + [neighbor, 'allowas-in', 'number']):
                     allowas_in = config.return_value(neighbor_path + [neighbor, 'allowas-in', 'number'])
                     config.set(neighbor_path + [neighbor] + af_path + ['allowas-in', 'number'], value=allowas_in)
                 config.delete(neighbor_path + [neighbor, 'allowas-in'])
 
             # Move attribute-unchanged options
             if config.exists(neighbor_path + [neighbor, 'attribute-unchanged']):
                 for attr in ['as-path', 'med', 'next-hop']:
                     if config.exists(neighbor_path + [neighbor, 'attribute-unchanged', attr]):
                         config.set(neighbor_path + [neighbor] + af_path + ['attribute-unchanged', attr])
                         config.delete(neighbor_path + [neighbor, 'attribute-unchanged', attr])
                 config.delete(neighbor_path + [neighbor, 'attribute-unchanged'])
 
             # Move capability options
             if config.exists(neighbor_path + [neighbor, 'capability']):
                 # "capability dynamic" is a peer-global option, we only migrate ORF
                 if config.exists(neighbor_path + [neighbor, 'capability', 'orf']):
                     if config.exists(neighbor_path + [neighbor, 'capability', 'orf', 'prefix-list']):
                         for orf in ['send', 'receive']:
                             if config.exists(neighbor_path + [neighbor, 'capability', 'orf', 'prefix-list', orf]):
                                 config.set(neighbor_path + [neighbor] + af_path + ['capability', 'orf', 'prefix-list', orf])
                                 config.delete(neighbor_path + [neighbor, 'capability', 'orf', 'prefix-list', orf])
                         config.delete(neighbor_path + [neighbor, 'capability', 'orf', 'prefix-list'])
                     config.delete(neighbor_path + [neighbor, 'capability', 'orf'])
 
             # Move default-originate
             if config.exists(neighbor_path + [neighbor, 'default-originate']):
                 if config.exists(neighbor_path + [neighbor, 'default-originate', 'route-map']):
                     route_map = config.return_value(neighbor_path + [neighbor, 'default-originate', 'route-map'])
                     config.set(neighbor_path + [neighbor] + af_path + ['default-originate', 'route-map'], value=route_map)
                 else:
                     # Empty default-originate node is meaningful so we re-create it
                     config.set(neighbor_path + [neighbor] + af_path + ['default-originate'])
                 config.delete(neighbor_path + [neighbor, 'default-originate'])
 
             # Move soft-reconfiguration
             if config.exists(neighbor_path + [neighbor, 'soft-reconfiguration']):
                 if config.exists(neighbor_path + [neighbor, 'soft-reconfiguration', 'inbound']):
                     config.set(neighbor_path + [neighbor] + af_path + ['soft-reconfiguration', 'inbound'])
                 # Empty soft-reconfiguration is meaningless, so we just remove it
                 config.delete(neighbor_path + [neighbor, 'soft-reconfiguration'])
 
             # Move disable-send-community
             if config.exists(neighbor_path + [neighbor, 'disable-send-community']):
                 for comm_type in ['standard', 'extended']:
                     if config.exists(neighbor_path + [neighbor, 'disable-send-community', comm_type]):
                         config.set(neighbor_path + [neighbor] + af_path + ['disable-send-community', comm_type])
                         config.delete(neighbor_path + [neighbor, 'disable-send-community', comm_type])
                 config.delete(neighbor_path + [neighbor, 'disable-send-community'])
 
 
-if not config.exists(['protocols', 'bgp']):
-    # Nothing to do
-    sys.exit(0)
-else:
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(['protocols', 'bgp']):
+        # Nothing to do
+        return
+
     # Just to avoid writing it so many times
     af_path = ['address-family', 'ipv4-unicast']
 
     # Check if BGP is actually configured and obtain the ASN
     asn_list = config.list_nodes(['protocols', 'bgp'])
     if asn_list:
         # There's always just one BGP node, if any
         asn = asn_list[0]
         bgp_path = ['protocols', 'bgp', asn]
     else:
         # There's actually no BGP, just its empty shell
-        sys.exit(0)
+        return
 
     ## Move global IPv4-specific BGP options to "address-family ipv4-unicast"
 
     # Move networks
     network_path = ['protocols', 'bgp', asn, 'network']
     if config.exists(network_path):
         config.set(bgp_path + af_path + ['network'])
         config.set_tag(bgp_path + af_path + ['network'])
 
         networks = config.list_nodes(network_path)
         for network in networks:
             config.set(bgp_path + af_path + ['network', network])
             if config.exists(network_path + [network, 'route-map']):
                 route_map = config.return_value(network_path + [network, 'route-map'])
                 config.set(bgp_path + af_path + ['network', network, 'route-map'], value=route_map)
         config.delete(network_path)
 
     # Move aggregate-address statements
     aggregate_path = ['protocols', 'bgp', asn, 'aggregate-address']
     if config.exists(aggregate_path):
         config.set(bgp_path + af_path + ['aggregate-address'])
         config.set_tag(bgp_path + af_path + ['aggregate-address'])
 
         aggregates = config.list_nodes(aggregate_path)
         for aggregate in aggregates:
             config.set(bgp_path + af_path + ['aggregate-address', aggregate])
             if config.exists(aggregate_path + [aggregate, 'as-set']):
                 config.set(bgp_path + af_path + ['aggregate-address', aggregate, 'as-set'])
             if config.exists(aggregate_path + [aggregate, 'summary-only']):
                 config.set(bgp_path + af_path + ['aggregate-address', aggregate, 'summary-only'])
         config.delete(aggregate_path)
 
     ## Migrate neighbor options
     neighbor_path = ['protocols', 'bgp', asn, 'neighbor']
     if config.exists(neighbor_path):
         neighbors = config.list_nodes(neighbor_path)
         for neighbor in neighbors:
             migrate_neighbor(config, neighbor_path, neighbor)
 
     peer_group_path = ['protocols', 'bgp', asn, 'peer-group']
     if config.exists(peer_group_path):
         peer_groups = config.list_nodes(peer_group_path)
         for peer_group in peer_groups:
             migrate_neighbor(config, peer_group_path, peer_group)
 
     ## Migrate redistribute statements
     redistribute_path = ['protocols', 'bgp', asn, 'redistribute']
     if config.exists(redistribute_path):
         config.set(bgp_path + af_path + ['redistribute'])
 
         redistributes = config.list_nodes(redistribute_path)
         for redistribute in redistributes:
             config.set(bgp_path + af_path + ['redistribute', redistribute])
             if config.exists(redistribute_path + [redistribute, 'metric']):
                 redist_metric = config.return_value(redistribute_path + [redistribute, 'metric'])
                 config.set(bgp_path + af_path + ['redistribute', redistribute, 'metric'], value=redist_metric)
             if config.exists(redistribute_path + [redistribute, 'route-map']):
                 redist_route_map = config.return_value(redistribute_path + [redistribute, 'route-map'])
                 config.set(bgp_path + af_path + ['redistribute', redistribute, 'route-map'], value=redist_route_map)
 
         config.delete(redistribute_path)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/quagga/3-to-4 b/src/migration-scripts/quagga/3-to-4
old mode 100755
new mode 100644
index 1e8c8e2f2..81cf139f6
--- a/src/migration-scripts/quagga/3-to-4
+++ b/src/migration-scripts/quagga/3-to-4
@@ -1,76 +1,52 @@
-#!/usr/bin/env python3
+# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-#
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Between 1.2.3 and 1.2.4, FRR added per-neighbor enforce-first-as option.
 # Unfortunately they also removed the global enforce-first-as option,
 # which broke all old configs that used to have it.
 #
 # To emulate the effect of the original option, we insert it in every neighbor
 # if the config used to have the original global option
 
-import sys
-
 from vyos.configtree import ConfigTree
 
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(['protocols', 'bgp']):
+        # Nothing to do
+        return
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
-if not config.exists(['protocols', 'bgp']):
-    # Nothing to do
-    sys.exit(0)
-else:
     # Check if BGP is actually configured and obtain the ASN
     asn_list = config.list_nodes(['protocols', 'bgp'])
     if asn_list:
         # There's always just one BGP node, if any
         asn = asn_list[0]
     else:
         # There's actually no BGP, just its empty shell
-        sys.exit(0)
+        return
 
     # Check if BGP enforce-first-as option is set
     enforce_first_as_path = ['protocols', 'bgp', asn, 'parameters', 'enforce-first-as']
     if config.exists(enforce_first_as_path):
         # Delete the obsolete option
         config.delete(enforce_first_as_path)
 
         # Now insert it in every peer
         peers = config.list_nodes(['protocols', 'bgp', asn, 'neighbor'])
         for p in peers:
             config.set(['protocols', 'bgp', asn, 'neighbor', p, 'enforce-first-as'])
     else:
         # Do nothing
-        sys.exit(0)
-
-    # Save a new configuration file
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
-
+        return
diff --git a/src/migration-scripts/quagga/4-to-5 b/src/migration-scripts/quagga/4-to-5
old mode 100755
new mode 100644
index fcb496a9c..27b995431
--- a/src/migration-scripts/quagga/4-to-5
+++ b/src/migration-scripts/quagga/4-to-5
@@ -1,63 +1,40 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2019 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-#
-
-import sys
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 from vyos.configtree import ConfigTree
 
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(['protocols', 'bgp']):
+        # Nothing to do
+        return
 
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
-if not config.exists(['protocols', 'bgp']):
-    # Nothing to do
-    sys.exit(0)
-else:
     # Check if BGP is actually configured and obtain the ASN
     asn_list = config.list_nodes(['protocols', 'bgp'])
     if asn_list:
         # There's always just one BGP node, if any
         asn = asn_list[0]
     else:
         # There's actually no BGP, just its empty shell
-        sys.exit(0)
+        return
 
     # Check if BGP scan-time parameter exist
     scan_time_param = ['protocols', 'bgp', asn, 'parameters', 'scan-time']
     if config.exists(scan_time_param):
         # Delete BGP scan-time parameter
         config.delete(scan_time_param)
     else:
         # Do nothing
-        sys.exit(0)
-
-    # Save a new configuration file
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
+        return
diff --git a/src/migration-scripts/quagga/5-to-6 b/src/migration-scripts/quagga/5-to-6
old mode 100755
new mode 100644
index f075fc2e7..08fd070de
--- a/src/migration-scripts/quagga/5-to-6
+++ b/src/migration-scripts/quagga/5-to-6
@@ -1,63 +1,40 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-# * Remove parameter 'disable-network-import-check' which, as implemented,
-#   had no effect on boot.
-
-import sys
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 from vyos.configtree import ConfigTree
 
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(['protocols', 'bgp']):
+        # Nothing to do
+        return
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
-if not config.exists(['protocols', 'bgp']):
-    # Nothing to do
-    sys.exit(0)
-else:
     # Check if BGP is actually configured and obtain the ASN
     asn_list = config.list_nodes(['protocols', 'bgp'])
     if asn_list:
         # There's always just one BGP node, if any
         asn = asn_list[0]
     else:
         # There's actually no BGP, just its empty shell
-        sys.exit(0)
+        return
 
     # Check if BGP parameter disable-network-import-check exists
     param = ['protocols', 'bgp', asn, 'parameters', 'disable-network-import-check']
     if config.exists(param):
         # Delete parameter
         config.delete(param)
     else:
         # Do nothing
-        sys.exit(0)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
+        return
diff --git a/src/migration-scripts/quagga/6-to-7 b/src/migration-scripts/quagga/6-to-7
old mode 100755
new mode 100644
index ed295a95c..095baac03
--- a/src/migration-scripts/quagga/6-to-7
+++ b/src/migration-scripts/quagga/6-to-7
@@ -1,116 +1,97 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - T3037, BGP address-family ipv6-unicast capability dynamic does not exist in
 #   FRR, there is only a base, per neighbor dynamic capability, migrate config
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 from vyos.template import is_ipv4
 from vyos.template import is_ipv6
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['protocols', 'bgp']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-# Check if BGP is actually configured and obtain the ASN
-asn_list = config.list_nodes(base)
-if asn_list:
-    # There's always just one BGP node, if any
-    bgp_base = base + [asn_list[0]]
-
-    for neighbor_type in ['neighbor', 'peer-group']:
-        if not config.exists(bgp_base + [neighbor_type]):
-            continue
-        for neighbor in config.list_nodes(bgp_base + [neighbor_type]):
-            # T2844 - add IPv4 AFI disable-send-community support
-            send_comm_path = bgp_base + [neighbor_type, neighbor, 'disable-send-community']
-            if config.exists(send_comm_path):
-                new_base = bgp_base + [neighbor_type, neighbor, 'address-family', 'ipv4-unicast']
-                config.set(new_base)
-                config.copy(send_comm_path, new_base + ['disable-send-community'])
-                config.delete(send_comm_path)
-
-            cap_dynamic = False
-            peer_group = None
-            for afi in ['ipv4-unicast', 'ipv6-unicast']:
-                afi_path = bgp_base + [neighbor_type, neighbor, 'address-family', afi]
-                # Exit loop early if AFI does not exist
-                if not config.exists(afi_path):
-                    continue
-
-                cap_path = afi_path + ['capability', 'dynamic']
-                if config.exists(cap_path):
-                    cap_dynamic = True
-                    config.delete(cap_path)
-
-                    # We have now successfully migrated the address-family
-                    # specific dynamic capability to the neighbor/peer-group
-                    # level. If this has been the only option under the
-                    # address-family nodes, we can clean them up by checking if
-                    # no other nodes are left under that tree and if so, delete
-                    # the parent.
-                    #
-                    # We walk from the most inner node to the most outer one.
-                    cleanup = -1
-                    while len(config.list_nodes(cap_path[:cleanup])) == 0:
-                        config.delete(cap_path[:cleanup])
-                        cleanup -= 1
-
-                peer_group_path = afi_path + ['peer-group']
-                if config.exists(peer_group_path):
-                    if ((is_ipv4(neighbor) and afi == 'ipv4-unicast') or
-                        (is_ipv6(neighbor) and afi == 'ipv6-unicast')):
-                        peer_group = config.return_value(peer_group_path)
-
-                    config.delete(peer_group_path)
-
-                    # We have now successfully migrated the address-family
-                    # specific peer-group to the neighbor level. If this has
-                    # been the only option under the address-family nodes, we
-                    # can clean them up by checking if no other nodes are left
-                    # under that tree and if so, delete the parent.
-                    #
-                    # We walk from the most inner node to the most outer one.
-                    cleanup = -1
-                    while len(config.list_nodes(peer_group_path[:cleanup])) == 0:
-                        config.delete(peer_group_path[:cleanup])
-                        cleanup -= 1
-
-            if cap_dynamic:
-                config.set(bgp_base + [neighbor_type, neighbor, 'capability', 'dynamic'])
-            if peer_group:
-                config.set(bgp_base + [neighbor_type, neighbor, 'peer-group'], value=peer_group)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    # Check if BGP is actually configured and obtain the ASN
+    asn_list = config.list_nodes(base)
+    if asn_list:
+        # There's always just one BGP node, if any
+        bgp_base = base + [asn_list[0]]
+
+        for neighbor_type in ['neighbor', 'peer-group']:
+            if not config.exists(bgp_base + [neighbor_type]):
+                continue
+            for neighbor in config.list_nodes(bgp_base + [neighbor_type]):
+                # T2844 - add IPv4 AFI disable-send-community support
+                send_comm_path = bgp_base + [neighbor_type, neighbor, 'disable-send-community']
+                if config.exists(send_comm_path):
+                    new_base = bgp_base + [neighbor_type, neighbor, 'address-family', 'ipv4-unicast']
+                    config.set(new_base)
+                    config.copy(send_comm_path, new_base + ['disable-send-community'])
+                    config.delete(send_comm_path)
+
+                cap_dynamic = False
+                peer_group = None
+                for afi in ['ipv4-unicast', 'ipv6-unicast']:
+                    afi_path = bgp_base + [neighbor_type, neighbor, 'address-family', afi]
+                    # Exit loop early if AFI does not exist
+                    if not config.exists(afi_path):
+                        continue
+
+                    cap_path = afi_path + ['capability', 'dynamic']
+                    if config.exists(cap_path):
+                        cap_dynamic = True
+                        config.delete(cap_path)
+
+                        # We have now successfully migrated the address-family
+                        # specific dynamic capability to the neighbor/peer-group
+                        # level. If this has been the only option under the
+                        # address-family nodes, we can clean them up by checking if
+                        # no other nodes are left under that tree and if so, delete
+                        # the parent.
+                        #
+                        # We walk from the most inner node to the most outer one.
+                        cleanup = -1
+                        while len(config.list_nodes(cap_path[:cleanup])) == 0:
+                            config.delete(cap_path[:cleanup])
+                            cleanup -= 1
+
+                    peer_group_path = afi_path + ['peer-group']
+                    if config.exists(peer_group_path):
+                        if ((is_ipv4(neighbor) and afi == 'ipv4-unicast') or
+                            (is_ipv6(neighbor) and afi == 'ipv6-unicast')):
+                            peer_group = config.return_value(peer_group_path)
+
+                        config.delete(peer_group_path)
+
+                        # We have now successfully migrated the address-family
+                        # specific peer-group to the neighbor level. If this has
+                        # been the only option under the address-family nodes, we
+                        # can clean them up by checking if no other nodes are left
+                        # under that tree and if so, delete the parent.
+                        #
+                        # We walk from the most inner node to the most outer one.
+                        cleanup = -1
+                        while len(config.list_nodes(peer_group_path[:cleanup])) == 0:
+                            config.delete(peer_group_path[:cleanup])
+                            cleanup -= 1
+
+                if cap_dynamic:
+                    config.set(bgp_base + [neighbor_type, neighbor, 'capability', 'dynamic'])
+                if peer_group:
+                    config.set(bgp_base + [neighbor_type, neighbor, 'peer-group'], value=peer_group)
diff --git a/src/migration-scripts/quagga/7-to-8 b/src/migration-scripts/quagga/7-to-8
old mode 100755
new mode 100644
index 8f11bf390..d9de26d15
--- a/src/migration-scripts/quagga/7-to-8
+++ b/src/migration-scripts/quagga/7-to-8
@@ -1,61 +1,42 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - T3391: Migrate "maximum-paths" setting from "protocols bgp asn maximum-paths"
 #   under the IPv4 address-family tree. Reason is we currently have no way in
 #   configuring this for IPv6 address-family. This mimics the FRR configuration.
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['protocols', 'bgp']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-# Check if BGP is actually configured and obtain the ASN
-asn_list = config.list_nodes(base)
-if asn_list:
-    # There's always just one BGP node, if any
-    bgp_base = base + [asn_list[0]]
-
-    maximum_paths = bgp_base + ['maximum-paths']
-    if config.exists(maximum_paths):
-        for bgp_type in ['ebgp', 'ibgp']:
-            if config.exists(maximum_paths + [bgp_type]):
-                new_base =  bgp_base + ['address-family', 'ipv4-unicast', 'maximum-paths']
-                config.set(new_base)
-                config.copy(maximum_paths + [bgp_type], new_base + [bgp_type])
-        config.delete(maximum_paths)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    # Check if BGP is actually configured and obtain the ASN
+    asn_list = config.list_nodes(base)
+    if asn_list:
+        # There's always just one BGP node, if any
+        bgp_base = base + [asn_list[0]]
+
+        maximum_paths = bgp_base + ['maximum-paths']
+        if config.exists(maximum_paths):
+            for bgp_type in ['ebgp', 'ibgp']:
+                if config.exists(maximum_paths + [bgp_type]):
+                    new_base =  bgp_base + ['address-family', 'ipv4-unicast', 'maximum-paths']
+                    config.set(new_base)
+                    config.copy(maximum_paths + [bgp_type], new_base + [bgp_type])
+            config.delete(maximum_paths)
diff --git a/src/migration-scripts/quagga/8-to-9 b/src/migration-scripts/quagga/8-to-9
old mode 100755
new mode 100644
index 0f683d5a1..eece6c15d
--- a/src/migration-scripts/quagga/8-to-9
+++ b/src/migration-scripts/quagga/8-to-9
@@ -1,137 +1,117 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - T2450: drop interface-route and interface-route6 from "protocols static"
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
 def migrate_interface_route(config, base, path, route_route6):
     """ Generic migration function which can be called on every instance of
     interface-route, beeing it ipv4, ipv6 or nested under the "static table" nodes.
 
     What we do?
       - Drop 'interface-route' or 'interface-route6' and migrate the route unter the
         'route' or 'route6' tag node.
     """
     if config.exists(base + path):
         for route in config.list_nodes(base + path):
             interface = config.list_nodes(base + path + [route, 'next-hop-interface'])
 
             tmp = base + path + [route, 'next-hop-interface']
             for interface in config.list_nodes(tmp):
                 new_base = base + [route_route6, route, 'interface']
                 config.set(new_base)
                 config.set_tag(base + [route_route6])
                 config.set_tag(new_base)
                 config.copy(tmp + [interface], new_base + [interface])
 
         config.delete(base + path)
 
 def migrate_route(config, base, path, route_route6):
     """ Generic migration function which can be called on every instance of
     route, beeing it ipv4, ipv6 or even nested under the static table nodes.
 
     What we do?
       - for consistency reasons rename next-hop-interface to interface
       - for consistency reasons rename next-hop-vrf to vrf
     """
     if config.exists(base + path):
         for route in config.list_nodes(base + path):
             next_hop = base + path + [route, 'next-hop']
             if config.exists(next_hop):
                 for gateway in config.list_nodes(next_hop):
                     # IPv4 routes calls it next-hop-interface, rename this to
                     # interface instead so it's consitent with IPv6
                     interface_path = next_hop + [gateway, 'next-hop-interface']
                     if config.exists(interface_path):
                         config.rename(interface_path, 'interface')
 
                     # When VRFs got introduced, I (c-po) named it next-hop-vrf,
                     # we can also call it vrf which is simply shorter.
                     vrf_path = next_hop + [gateway, 'next-hop-vrf']
                     if config.exists(vrf_path):
                         config.rename(vrf_path, 'vrf')
 
             next_hop = base + path + [route, 'interface']
             if config.exists(next_hop):
                 for interface in config.list_nodes(next_hop):
                     # IPv4 routes calls it next-hop-interface, rename this to
                     # interface instead so it's consitent with IPv6
                     interface_path = next_hop + [interface, 'next-hop-interface']
                     if config.exists(interface_path):
                         config.rename(interface_path, 'interface')
 
                     # When VRFs got introduced, I (c-po) named it next-hop-vrf,
                     # we can also call it vrf which is simply shorter.
                     vrf_path = next_hop + [interface, 'next-hop-vrf']
                     if config.exists(vrf_path):
                         config.rename(vrf_path, 'vrf')
 
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
+base = ['protocols', 'static']
 
-file_name = argv[1]
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+    # Migrate interface-route into route
+    migrate_interface_route(config, base, ['interface-route'], 'route')
 
-base = ['protocols', 'static']
+    # Migrate interface-route6 into route6
+    migrate_interface_route(config, base, ['interface-route6'], 'route6')
 
-config = ConfigTree(config_file)
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
+    # Cleanup nodes inside route
+    migrate_route(config, base, ['route'], 'route')
 
-# Migrate interface-route into route
-migrate_interface_route(config, base, ['interface-route'], 'route')
+    # Cleanup nodes inside route6
+    migrate_route(config, base, ['route6'], 'route6')
 
-# Migrate interface-route6 into route6
-migrate_interface_route(config, base, ['interface-route6'], 'route6')
+    #
+    # PBR table cleanup
+    table_path = base + ['table']
+    if config.exists(table_path):
+        for table in config.list_nodes(table_path):
+            # Migrate interface-route into route
+            migrate_interface_route(config, table_path + [table], ['interface-route'], 'route')
 
-# Cleanup nodes inside route
-migrate_route(config, base, ['route'], 'route')
+            # Migrate interface-route6 into route6
+            migrate_interface_route(config, table_path + [table], ['interface-route6'], 'route6')
 
-# Cleanup nodes inside route6
-migrate_route(config, base, ['route6'], 'route6')
+            # Cleanup nodes inside route
+            migrate_route(config, table_path + [table], ['route'], 'route')
 
-#
-# PBR table cleanup
-table_path = base + ['table']
-if config.exists(table_path):
-    for table in config.list_nodes(table_path):
-        # Migrate interface-route into route
-        migrate_interface_route(config, table_path + [table], ['interface-route'], 'route')
-
-        # Migrate interface-route6 into route6
-        migrate_interface_route(config, table_path + [table], ['interface-route6'], 'route6')
-
-        # Cleanup nodes inside route
-        migrate_route(config, table_path + [table], ['route'], 'route')
-
-        # Cleanup nodes inside route6
-        migrate_route(config, table_path + [table], ['route6'], 'route6')
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+            # Cleanup nodes inside route6
+            migrate_route(config, table_path + [table], ['route6'], 'route6')
diff --git a/src/migration-scripts/quagga/9-to-10 b/src/migration-scripts/quagga/9-to-10
old mode 100755
new mode 100644
index 3731762f7..4ac1f0b7d
--- a/src/migration-scripts/quagga/9-to-10
+++ b/src/migration-scripts/quagga/9-to-10
@@ -1,62 +1,42 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # re-organize route-map as-path
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['policy', 'route-map']
 
-config = ConfigTree(config_file)
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-for route_map in config.list_nodes(base):
-    # Bail out Early
-    if not config.exists(base + [route_map, 'rule']):
-        continue
-
-    for rule in config.list_nodes(base + [route_map, 'rule']):
-        rule_base = base + [route_map, 'rule', rule]
-        if config.exists(rule_base + ['set', 'as-path-exclude']):
-            tmp = config.return_value(rule_base + ['set', 'as-path-exclude'])
-            config.delete(rule_base + ['set', 'as-path-exclude'])
-            config.set(rule_base + ['set', 'as-path', 'exclude'], value=tmp)
-
-        if config.exists(rule_base + ['set', 'as-path-prepend']):
-            tmp = config.return_value(rule_base + ['set', 'as-path-prepend'])
-            config.delete(rule_base + ['set', 'as-path-prepend'])
-            config.set(rule_base + ['set', 'as-path', 'prepend'], value=tmp)
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    for route_map in config.list_nodes(base):
+        # Bail out Early
+        if not config.exists(base + [route_map, 'rule']):
+            continue
+
+        for rule in config.list_nodes(base + [route_map, 'rule']):
+            rule_base = base + [route_map, 'rule', rule]
+            if config.exists(rule_base + ['set', 'as-path-exclude']):
+                tmp = config.return_value(rule_base + ['set', 'as-path-exclude'])
+                config.delete(rule_base + ['set', 'as-path-exclude'])
+                config.set(rule_base + ['set', 'as-path', 'exclude'], value=tmp)
+
+            if config.exists(rule_base + ['set', 'as-path-prepend']):
+                tmp = config.return_value(rule_base + ['set', 'as-path-prepend'])
+                config.delete(rule_base + ['set', 'as-path-prepend'])
+                config.set(rule_base + ['set', 'as-path', 'prepend'], value=tmp)
diff --git a/src/migration-scripts/reverse-proxy/0-to-1 b/src/migration-scripts/reverse-proxy/0-to-1
old mode 100755
new mode 100644
index d61493815..b495474a6
--- a/src/migration-scripts/reverse-proxy/0-to-1
+++ b/src/migration-scripts/reverse-proxy/0-to-1
@@ -1,48 +1,31 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T6409: Remove unused 'backend bk-example parameters' node
 
-from sys import argv, exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['load-balancing', 'reverse-proxy', 'backend']
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
 
-# we need to run this for every configured network
-for backend in config.list_nodes(base):
-    param_node = base + [backend, 'parameters']
-    if config.exists(param_node):
-        config.delete(param_node)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    # we need to run this for every configured network
+    for backend in config.list_nodes(base):
+        param_node = base + [backend, 'parameters']
+        if config.exists(param_node):
+            config.delete(param_node)
diff --git a/src/migration-scripts/rip/0-to-1 b/src/migration-scripts/rip/0-to-1
old mode 100755
new mode 100644
index 08a866374..6d41bcf58
--- a/src/migration-scripts/rip/0-to-1
+++ b/src/migration-scripts/rip/0-to-1
@@ -1,51 +1,31 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T5150: Rework CLI definitions to apply route-maps between routing daemons
 #        and zebra/kernel
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 ripng_base = ['protocols', 'ripng']
-# Check if RIPng is configured - if so, migrate the CLI node
-if config.exists(ripng_base):
-    if config.exists(ripng_base + ['route-map']):
-        tmp = config.return_value(ripng_base + ['route-map'])
 
-        config.set(['system', 'ipv6', 'protocol', 'ripng', 'route-map'], value=tmp)
-        config.set_tag(['system', 'ipv6', 'protocol'])
-        config.delete(ripng_base + ['route-map'])
+def migrate(config: ConfigTree) -> None:
+    # Check if RIPng is configured - if so, migrate the CLI node
+    if config.exists(ripng_base):
+        if config.exists(ripng_base + ['route-map']):
+            tmp = config.return_value(ripng_base + ['route-map'])
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+            config.set(['system', 'ipv6', 'protocol', 'ripng', 'route-map'], value=tmp)
+            config.set_tag(['system', 'ipv6', 'protocol'])
+            config.delete(ripng_base + ['route-map'])
diff --git a/src/migration-scripts/rpki/0-to-1 b/src/migration-scripts/rpki/0-to-1
old mode 100755
new mode 100644
index a7b5d07d5..b6e781fa9
--- a/src/migration-scripts/rpki/0-to-1
+++ b/src/migration-scripts/rpki/0-to-1
@@ -1,63 +1,44 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-from sys import exit
-from sys import argv
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['protocols', 'rpki']
-config = ConfigTree(config_file)
-
-# Nothing to do
-if not config.exists(base):
-    exit(0)
-
-if config.exists(base + ['cache']):
-    preference = 1
-    for cache in config.list_nodes(base + ['cache']):
-        address_node = base + ['cache', cache, 'address']
-        if config.exists(address_node):
-            address = config.return_value(address_node)
-            # We do not longer support the address leafNode, RPKI cache server
-            # IP address is now used from the tagNode
-            config.delete(address_node)
-            # VyOS 1.2 had no per instance preference, setting new defaults
-            config.set(base + ['cache', cache, 'preference'], value=preference)
-            # Increase preference for the next caching peer - actually VyOS 1.2
-            # supported only one but better save then sorry (T3253)
-            preference += 1
-
-            # T3293: If the RPKI cache name equals the configured address,
-            # renaming is not possible, as rename expects the new path to not
-            # exist.
-            if not config.exists(base + ['cache', address]):
-                config.rename(base + ['cache', cache], address)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    # Nothing to do
+    if not config.exists(base):
+        return
+
+    if config.exists(base + ['cache']):
+        preference = 1
+        for cache in config.list_nodes(base + ['cache']):
+            address_node = base + ['cache', cache, 'address']
+            if config.exists(address_node):
+                address = config.return_value(address_node)
+                # We do not longer support the address leafNode, RPKI cache server
+                # IP address is now used from the tagNode
+                config.delete(address_node)
+                # VyOS 1.2 had no per instance preference, setting new defaults
+                config.set(base + ['cache', cache, 'preference'], value=preference)
+                # Increase preference for the next caching peer - actually VyOS 1.2
+                # supported only one but better save then sorry (T3253)
+                preference += 1
+
+                # T3293: If the RPKI cache name equals the configured address,
+                # renaming is not possible, as rename expects the new path to not
+                # exist.
+                if not config.exists(base + ['cache', address]):
+                    config.rename(base + ['cache', cache], address)
diff --git a/src/migration-scripts/rpki/1-to-2 b/src/migration-scripts/rpki/1-to-2
old mode 100755
new mode 100644
index 50d4a3dfc..855236d6c
--- a/src/migration-scripts/rpki/1-to-2
+++ b/src/migration-scripts/rpki/1-to-2
@@ -1,73 +1,53 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T6011: rpki: known-hosts-file is no longer supported bxy FRR CLI,
 #        remove VyOS CLI node
 
-from sys import exit
-from sys import argv
-
 from vyos.configtree import ConfigTree
 from vyos.pki import OPENSSH_KEY_BEGIN
 from vyos.pki import OPENSSH_KEY_END
 from vyos.utils.file import read_file
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['protocols', 'rpki']
-config = ConfigTree(config_file)
-
-# Nothing to do
-if not config.exists(base):
-    exit(0)
-
-if config.exists(base + ['cache']):
-    for cache in config.list_nodes(base + ['cache']):
-        ssh_node = base + ['cache', cache, 'ssh']
-        if config.exists(ssh_node + ['known-hosts-file']):
-            config.delete(ssh_node + ['known-hosts-file'])
-
-        if config.exists(base + ['cache', cache, 'ssh']):
-            private_key_node = base + ['cache', cache, 'ssh', 'private-key-file']
-            private_key_file = config.return_value(private_key_node)
-            private_key = read_file(private_key_file).replace(OPENSSH_KEY_BEGIN, '').replace(OPENSSH_KEY_END, '').replace('\n','')
-
-            public_key_node = base + ['cache', cache, 'ssh', 'public-key-file']
-            public_key_file = config.return_value(public_key_node)
-            public_key = read_file(public_key_file).split()
-
-            config.set(['pki', 'openssh', f'rpki-{cache}', 'private', 'key'], value=private_key)
-            config.set(['pki', 'openssh', f'rpki-{cache}', 'public', 'key'], value=public_key[1])
-            config.set(['pki', 'openssh', f'rpki-{cache}', 'public', 'type'], value=public_key[0])
-            config.set_tag(['pki', 'openssh'])
-            config.set(ssh_node + ['key'], value=f'rpki-{cache}')
-
-            config.delete(private_key_node)
-            config.delete(public_key_node)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    # Nothing to do
+    if not config.exists(base):
+        return
+
+    if config.exists(base + ['cache']):
+        for cache in config.list_nodes(base + ['cache']):
+            ssh_node = base + ['cache', cache, 'ssh']
+            if config.exists(ssh_node + ['known-hosts-file']):
+                config.delete(ssh_node + ['known-hosts-file'])
+
+            if config.exists(base + ['cache', cache, 'ssh']):
+                private_key_node = base + ['cache', cache, 'ssh', 'private-key-file']
+                private_key_file = config.return_value(private_key_node)
+                private_key = read_file(private_key_file).replace(OPENSSH_KEY_BEGIN, '').replace(OPENSSH_KEY_END, '').replace('\n','')
+
+                public_key_node = base + ['cache', cache, 'ssh', 'public-key-file']
+                public_key_file = config.return_value(public_key_node)
+                public_key = read_file(public_key_file).split()
+
+                config.set(['pki', 'openssh', f'rpki-{cache}', 'private', 'key'], value=private_key)
+                config.set(['pki', 'openssh', f'rpki-{cache}', 'public', 'key'], value=public_key[1])
+                config.set(['pki', 'openssh', f'rpki-{cache}', 'public', 'type'], value=public_key[0])
+                config.set_tag(['pki', 'openssh'])
+                config.set(ssh_node + ['key'], value=f'rpki-{cache}')
+
+                config.delete(private_key_node)
+                config.delete(public_key_node)
diff --git a/src/migration-scripts/salt/0-to-1 b/src/migration-scripts/salt/0-to-1
old mode 100755
new mode 100644
index 481d9de8f..3990a88dc
--- a/src/migration-scripts/salt/0-to-1
+++ b/src/migration-scripts/salt/0-to-1
@@ -1,58 +1,38 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Delete log_file, log_level and user nodes
 # rename hash_type to hash
 # rename mine_interval to interval
 
-from sys import argv,exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 base = ['service', 'salt-minion']
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-else:
+
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
     # delete nodes which are now populated with sane defaults
     for node in ['log_file', 'log_level', 'user']:
         if config.exists(base + [node]):
             config.delete(base + [node])
 
     if config.exists(base + ['hash_type']):
         config.rename(base + ['hash_type'], 'hash')
 
     if config.exists(base + ['mine_interval']):
         config.rename(base + ['mine_interval'], 'interval')
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
diff --git a/src/migration-scripts/snmp/0-to-1 b/src/migration-scripts/snmp/0-to-1
old mode 100755
new mode 100644
index b1e61b958..03b190cb7
--- a/src/migration-scripts/snmp/0-to-1
+++ b/src/migration-scripts/snmp/0-to-1
@@ -1,56 +1,38 @@
-#!/usr/bin/env python3
+# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2019 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-import sys
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 config_base = ['service', 'snmp', 'v3']
 
-if not config.exists(config_base):
-    # Nothing to do
-    sys.exit(0)
-else:
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(config_base):
+        # Nothing to do
+        return
+
     # we no longer support a per trap target engine ID (https://vyos.dev/T818)
     if config.exists(config_base + ['v3', 'trap-target']):
         for target in config.list_nodes(config_base + ['v3', 'trap-target']):
             config.delete(config_base + ['v3', 'trap-target', target, 'engineid'])
 
     # we no longer support a per user engine ID (https://vyos.dev/T818)
     if config.exists(config_base + ['v3', 'user']):
         for user in config.list_nodes(config_base + ['v3', 'user']):
             config.delete(config_base + ['v3', 'user', user, 'engineid'])
 
     # we drop TSM support as there seem to be no users and this code is untested
     # https://vyos.dev/T1769
     if config.exists(config_base + ['v3', 'tsm']):
         config.delete(config_base + ['v3', 'tsm'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/snmp/1-to-2 b/src/migration-scripts/snmp/1-to-2
old mode 100755
new mode 100644
index e02cd1aa1..0120f8acb
--- a/src/migration-scripts/snmp/1-to-2
+++ b/src/migration-scripts/snmp/1-to-2
@@ -1,89 +1,70 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-from sys import argv, exit
 from vyos.configtree import ConfigTree
 
+# We no longer support hashed values prefixed with '0x' to unclutter
+# CLI and also calculate the hases in advance instead of retrieving
+# them after service startup - which was always a bad idea
+prefix = '0x'
+
 def migrate_keys(config, path):
     # authentication: rename node 'encrypted-key' -> 'encrypted-password'
     config_path_auth = path + ['auth', 'encrypted-key']
     if config.exists(config_path_auth):
         config.rename(config_path_auth, 'encrypted-password')
         config_path_auth = path + ['auth', 'encrypted-password']
 
         # remove leading '0x' from string if present
         tmp = config.return_value(config_path_auth)
         if tmp.startswith(prefix):
             tmp = tmp.replace(prefix, '')
             config.set(config_path_auth, value=tmp)
 
     # privacy: rename node 'encrypted-key' -> 'encrypted-password'
     config_path_priv = path + ['privacy', 'encrypted-key']
     if config.exists(config_path_priv):
         config.rename(config_path_priv, 'encrypted-password')
         config_path_priv = path + ['privacy', 'encrypted-password']
 
         # remove leading '0x' from string if present
         tmp = config.return_value(config_path_priv)
         if tmp.startswith(prefix):
             tmp = tmp.replace(prefix, '')
             config.set(config_path_priv, value=tmp)
 
-if __name__ == '__main__':
-    if len(argv) < 2:
-        print("Must specify file name!")
-        exit(1)
-
-    file_name = argv[1]
-
-    with open(file_name, 'r') as f:
-        config_file = f.read()
-
-    config = ConfigTree(config_file)
+def migrate(config: ConfigTree) -> None:
     config_base = ['service', 'snmp', 'v3']
 
     if not config.exists(config_base):
         # Nothing to do
-        exit(0)
-    else:
-        # We no longer support hashed values prefixed with '0x' to unclutter
-        # CLI and also calculate the hases in advance instead of retrieving
-        # them after service startup - which was always a bad idea
-        prefix = '0x'
-
-        config_engineid = config_base + ['engineid']
-        if config.exists(config_engineid):
-            tmp = config.return_value(config_engineid)
-            if tmp.startswith(prefix):
-                tmp = tmp.replace(prefix, '')
-                config.set(config_engineid, value=tmp)
+        return
 
-        config_user = config_base + ['user']
-        if config.exists(config_user):
-            for user in config.list_nodes(config_user):
-                migrate_keys(config, config_user + [user])
+    config_engineid = config_base + ['engineid']
+    if config.exists(config_engineid):
+        tmp = config.return_value(config_engineid)
+        if tmp.startswith(prefix):
+            tmp = tmp.replace(prefix, '')
+            config.set(config_engineid, value=tmp)
 
-        config_trap = config_base + ['trap-target']
-        if config.exists(config_trap):
-            for trap in config.list_nodes(config_trap):
-                migrate_keys(config, config_trap + [trap])
+    config_user = config_base + ['user']
+    if config.exists(config_user):
+        for user in config.list_nodes(config_user):
+            migrate_keys(config, config_user + [user])
 
-        try:
-            with open(file_name, 'w') as f:
-                f.write(config.to_string())
-        except OSError as e:
-            print("Failed to save the modified config: {}".format(e))
-            exit(1)
+    config_trap = config_base + ['trap-target']
+    if config.exists(config_trap):
+        for trap in config.list_nodes(config_trap):
+            migrate_keys(config, config_trap + [trap])
diff --git a/src/migration-scripts/snmp/2-to-3 b/src/migration-scripts/snmp/2-to-3
old mode 100755
new mode 100644
index ab9b5dcba..6d828b619
--- a/src/migration-scripts/snmp/2-to-3
+++ b/src/migration-scripts/snmp/2-to-3
@@ -1,54 +1,33 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T4857: Implement FRR SNMP recomendations
 #  cli changes from:
 #  set service snmp oid-enable route-table
 #  To
 #  set service snmp oid-enable ip-forward
 
-from sys import argv
-from sys import exit
-
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['service snmp']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-if config.exists(base + ['oid-enable']):
-    config.delete(base + ['oid-enable'])
-    config.set(base + ['oid-enable'], 'ip-forward')
 
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    if config.exists(base + ['oid-enable']):
+        config.delete(base + ['oid-enable'])
+        config.set(base + ['oid-enable'], 'ip-forward')
diff --git a/src/migration-scripts/ssh/0-to-1 b/src/migration-scripts/ssh/0-to-1
old mode 100755
new mode 100644
index 2595599ac..65b68f509
--- a/src/migration-scripts/ssh/0-to-1
+++ b/src/migration-scripts/ssh/0-to-1
@@ -1,32 +1,26 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Delete "service ssh allow-root" option
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(['service', 'ssh', 'allow-root']):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-
-if not config.exists(['service', 'ssh', 'allow-root']):
-    # Nothing to do
-    sys.exit(0)
-else:
     # Delete node with abandoned command
     config.delete(['service', 'ssh', 'allow-root'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/ssh/1-to-2 b/src/migration-scripts/ssh/1-to-2
old mode 100755
new mode 100644
index 79d65d7d4..b601db3b4
--- a/src/migration-scripts/ssh/1-to-2
+++ b/src/migration-scripts/ssh/1-to-2
@@ -1,81 +1,63 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020-2022 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # VyOS 1.2 crux allowed configuring a lower or upper case loglevel. This
 # is no longer supported as the input data is validated and will lead to
 # an error. If user specifies an upper case logleve, make it lowercase
 
-from sys import argv,exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['service', 'ssh']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-path_loglevel = base + ['loglevel']
-if config.exists(path_loglevel):
-    # red in configured loglevel and convert it to lower case
-    tmp = config.return_value(path_loglevel).lower()
-    # VyOS 1.2 had no proper value validation on the CLI thus the
-    # user could use any arbitrary values - sanitize them
-    if tmp not in ['quiet', 'fatal', 'error', 'info', 'verbose']:
-        tmp = 'info'
-    config.set(path_loglevel, value=tmp)
-
-# T4273: migrate ssh cipher list to multi node
-path_ciphers = base + ['ciphers']
-if config.exists(path_ciphers):
-    tmp = []
-    # get curtrent cipher list - comma delimited
-    for cipher in config.return_values(path_ciphers):
-        tmp.extend(cipher.split(','))
-    # delete old cipher suite representation
-    config.delete(path_ciphers)
-
-    for cipher in tmp:
-        config.set(path_ciphers, value=cipher, replace=False)
-
-# T4273: migrate ssh key-exchange list to multi node
-path_kex = base + ['key-exchange']
-if config.exists(path_kex):
-    tmp = []
-    # get curtrent cipher list - comma delimited
-    for kex in config.return_values(path_kex):
-        tmp.extend(kex.split(','))
-    # delete old cipher suite representation
-    config.delete(path_kex)
-
-    for kex in tmp:
-        config.set(path_kex, value=kex, replace=False)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    path_loglevel = base + ['loglevel']
+    if config.exists(path_loglevel):
+        # red in configured loglevel and convert it to lower case
+        tmp = config.return_value(path_loglevel).lower()
+        # VyOS 1.2 had no proper value validation on the CLI thus the
+        # user could use any arbitrary values - sanitize them
+        if tmp not in ['quiet', 'fatal', 'error', 'info', 'verbose']:
+            tmp = 'info'
+        config.set(path_loglevel, value=tmp)
+
+    # T4273: migrate ssh cipher list to multi node
+    path_ciphers = base + ['ciphers']
+    if config.exists(path_ciphers):
+        tmp = []
+        # get curtrent cipher list - comma delimited
+        for cipher in config.return_values(path_ciphers):
+            tmp.extend(cipher.split(','))
+        # delete old cipher suite representation
+        config.delete(path_ciphers)
+
+        for cipher in tmp:
+            config.set(path_ciphers, value=cipher, replace=False)
+
+    # T4273: migrate ssh key-exchange list to multi node
+    path_kex = base + ['key-exchange']
+    if config.exists(path_kex):
+        tmp = []
+        # get curtrent cipher list - comma delimited
+        for kex in config.return_values(path_kex):
+            tmp.extend(kex.split(','))
+        # delete old cipher suite representation
+        config.delete(path_kex)
+
+        for kex in tmp:
+            config.set(path_kex, value=kex, replace=False)
diff --git a/src/migration-scripts/sstp/0-to-1 b/src/migration-scripts/sstp/0-to-1
old mode 100755
new mode 100644
index 150127aaf..1bd7d6c6b
--- a/src/migration-scripts/sstp/0-to-1
+++ b/src/migration-scripts/sstp/0-to-1
@@ -1,128 +1,109 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - migrate from "service sstp-server" to "vpn sstp"
 # - remove primary/secondary identifier from nameserver
 # - migrate RADIUS configuration to a more uniform syntax accross the system
 #   - authentication radius-server x.x.x.x to authentication radius server x.x.x.x
 #   - authentication radius-settings to authentication radius
 #   - do not migrate radius server req-limit, use default of unlimited
 # - migrate SSL certificate path
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
+old_base = ['service', 'sstp-server']
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(old_base):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-old_base = ['service', 'sstp-server']
-if not config.exists(old_base):
-    # Nothing to do
-    sys.exit(0)
-else:
     # ensure new base path exists
     if not config.exists(['vpn']):
         config.set(['vpn'])
 
     new_base = ['vpn', 'sstp']
     # copy entire tree
     config.copy(old_base, new_base)
     config.delete(old_base)
 
     # migrate DNS servers
     dns_base = new_base + ['network-settings', 'dns-server']
     if config.exists(dns_base):
         if config.exists(dns_base + ['primary-dns']):
             dns = config.return_value(dns_base + ['primary-dns'])
             config.set(new_base + ['network-settings', 'name-server'], value=dns, replace=False)
 
         if config.exists(dns_base + ['secondary-dns']):
             dns = config.return_value(dns_base + ['secondary-dns'])
             config.set(new_base + ['network-settings', 'name-server'], value=dns, replace=False)
 
         config.delete(dns_base)
 
 
     # migrate radius options - copy subtree
     # thus must happen before migration of the individual RADIUS servers
     old_options = new_base + ['authentication', 'radius-settings']
     if config.exists(old_options):
         new_options = new_base + ['authentication', 'radius']
         config.copy(old_options, new_options)
         config.delete(old_options)
 
     # migrate radius dynamic author / change of authorisation server
     dae_old = new_base + ['authentication', 'radius', 'dae-server']
     if config.exists(dae_old):
         config.rename(dae_old, 'dynamic-author')
         dae_new = new_base + ['authentication', 'radius', 'dynamic-author']
 
         if config.exists(dae_new + ['ip-address']):
             config.rename(dae_new + ['ip-address'], 'server')
 
         if config.exists(dae_new + ['secret']):
             config.rename(dae_new + ['secret'], 'key')
 
 
     # migrate radius server
     radius_server = new_base + ['authentication', 'radius-server']
     if config.exists(radius_server):
         for server in config.list_nodes(radius_server):
             base = radius_server + [server]
             new = new_base + ['authentication', 'radius', 'server', server]
 
             # convert secret to key
             if config.exists(base + ['secret']):
                 tmp = config.return_value(base + ['secret'])
                 config.set(new + ['key'], value=tmp)
 
             if config.exists(base + ['fail-time']):
                 tmp = config.return_value(base + ['fail-time'])
                 config.set(new + ['fail-time'], value=tmp)
 
         config.set_tag(new_base + ['authentication', 'radius', 'server'])
         config.delete(radius_server)
 
     # migrate SSL certificates
     old_ssl = new_base + ['sstp-settings']
     new_ssl = new_base + ['ssl']
     config.copy(old_ssl + ['ssl-certs'], new_ssl)
     config.delete(old_ssl)
 
     if config.exists(new_ssl + ['ca']):
         config.rename(new_ssl + ['ca'], 'ca-cert-file')
 
     if config.exists(new_ssl + ['server-cert']):
         config.rename(new_ssl + ['server-cert'], 'cert-file')
 
     if config.exists(new_ssl + ['server-key']):
         config.rename(new_ssl + ['server-key'], 'key-file')
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/sstp/1-to-2 b/src/migration-scripts/sstp/1-to-2
old mode 100755
new mode 100644
index f7ecbb6d4..2349e3c9f
--- a/src/migration-scripts/sstp/1-to-2
+++ b/src/migration-scripts/sstp/1-to-2
@@ -1,110 +1,93 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - migrate relative path SSL certificate to absolute path, as certs are only
 #   allowed to stored in /config/user-data/sstp/ this is pretty straight
 #   forward move. Delete certificates from source directory
 
 import os
-import sys
 
 from shutil import copy2
 from stat import S_IRUSR, S_IWUSR, S_IRGRP, S_IROTH
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
+base_path = ['vpn', 'sstp', 'ssl']
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base_path):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-base_path = ['vpn', 'sstp', 'ssl']
-if not config.exists(base_path):
-    # Nothing to do
-    sys.exit(0)
-else:
     cert_path_old ='/config/user-data/sstp/'
     cert_path_new ='/config/auth/sstp/'
 
     if not os.path.isdir(cert_path_new):
         os.mkdir(cert_path_new)
 
     #
     # migrate ca-cert-file to new path
     if config.exists(base_path + ['ca-cert-file']):
         tmp = config.return_value(base_path + ['ca-cert-file'])
         cert_old = cert_path_old + tmp
         cert_new = cert_path_new + tmp
 
         if os.path.isfile(cert_old):
             # adjust file permissions on source file,
             # permissions will be copied by copy2()
             os.chmod(cert_old, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH)
             copy2(cert_old, cert_path_new)
             # delete old certificate file
             os.unlink(cert_old)
 
         config.set(base_path + ['ca-cert-file'], value=cert_new, replace=True)
 
     #
     # migrate cert-file to new path
     if config.exists(base_path + ['cert-file']):
         tmp = config.return_value(base_path + ['cert-file'])
         cert_old = cert_path_old + tmp
         cert_new = cert_path_new + tmp
 
         if os.path.isfile(cert_old):
             # adjust file permissions on source file,
             # permissions will be copied by copy2()
             os.chmod(cert_old, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH)
             copy2(cert_old, cert_path_new)
             # delete old certificate file
             os.unlink(cert_old)
 
         config.set(base_path + ['cert-file'], value=cert_new, replace=True)
 
     #
     # migrate key-file to new path
     if config.exists(base_path + ['key-file']):
         tmp = config.return_value(base_path + ['key-file'])
         cert_old = cert_path_old + tmp
         cert_new = cert_path_new + tmp
 
         if os.path.isfile(cert_old):
             # adjust file permissions on source file,
             # permissions will be copied by copy2()
             os.chmod(cert_old, S_IRUSR | S_IWUSR)
             copy2(cert_old, cert_path_new)
             # delete old certificate file
             os.unlink(cert_old)
 
         config.set(base_path + ['key-file'], value=cert_new, replace=True)
 
     #
     # check if old certificate directory exists but is empty
     if os.path.isdir(cert_path_old) and not os.listdir(cert_path_old):
         os.rmdir(cert_path_old)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/sstp/2-to-3 b/src/migration-scripts/sstp/2-to-3
old mode 100755
new mode 100644
index 245db7ad6..4255a896e
--- a/src/migration-scripts/sstp/2-to-3
+++ b/src/migration-scripts/sstp/2-to-3
@@ -1,78 +1,59 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - Rename SSTP ppp-settings node to ppp-options to make use of a common
 #   Jinja Template to render Accel-PPP services
 
 from vyos.configtree import ConfigTree
-from sys import argv
-from sys import exit
-
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
 
-file_name = argv[1]
+base_path = ['vpn', 'sstp']
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base_path):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-base_path = ['vpn', 'sstp']
-if not config.exists(base_path):
-    # Nothing to do
-    exit(0)
-else:
     if config.exists(base_path + ['ppp-settings']):
         config.rename(base_path + ['ppp-settings'], 'ppp-options')
 
     config_ns = base_path + ['network-settings', 'name-server']
     if config.exists(config_ns):
         config.copy(config_ns, base_path + ['name-server'])
         config.delete(config_ns)
 
     config_mtu = base_path + ['network-settings', 'mtu']
     if config.exists(config_mtu):
         config.copy(config_mtu, base_path + ['mtu'])
         config.delete(config_mtu)
 
     config_gw = base_path + ['network-settings', 'client-ip-settings', 'gateway-address']
     if config.exists(config_gw):
         config.copy(config_gw, base_path + ['gateway-address'])
         config.delete(config_gw)
 
     config_client_ip = base_path + ['network-settings', 'client-ip-settings']
     if config.exists(config_client_ip):
         config.copy(config_client_ip, base_path + ['client-ip-pool'])
         config.delete(config_client_ip)
 
     config_client_ipv6 = base_path + ['network-settings', 'client-ipv6-pool']
     if config.exists(config_client_ipv6):
         config.copy(config_client_ipv6, base_path + ['client-ipv6-pool'])
         config.delete(config_client_ipv6)
 
     # all nodes now have been migrated out of network-settings - delete node
     config_nw_settings = base_path + ['network-settings']
     if config.exists(config_nw_settings):
         config.delete(config_nw_settings)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        exit(1)
-
diff --git a/src/migration-scripts/sstp/3-to-4 b/src/migration-scripts/sstp/3-to-4
old mode 100755
new mode 100644
index 5b7757e60..fd10985de
--- a/src/migration-scripts/sstp/3-to-4
+++ b/src/migration-scripts/sstp/3-to-4
@@ -1,135 +1,116 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - Update SSL to use PKI configuration
 
 import os
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 from vyos.pki import load_certificate
 from vyos.pki import load_private_key
 from vyos.pki import encode_certificate
 from vyos.pki import encode_private_key
 from vyos.utils.process import run
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['vpn', 'sstp']
 pki_base = ['pki']
 
-if not config.exists(base):
-    exit(0)
-
 AUTH_DIR = '/config/auth'
 
 def wrapped_pem_to_config_value(pem):
     return "".join(pem.strip().split("\n")[1:-1])
 
-if not config.exists(base + ['ssl']):
-    exit(0)
-
-x509_base = base + ['ssl']
-pki_name = 'sstp'
-
-if not config.exists(pki_base + ['ca']):
-    config.set(pki_base + ['ca'])
-    config.set_tag(pki_base + ['ca'])
-
-if not config.exists(pki_base + ['certificate']):
-    config.set(pki_base + ['certificate'])
-    config.set_tag(pki_base + ['certificate'])
-
-if config.exists(x509_base + ['ca-cert-file']):
-    cert_file = config.return_value(x509_base + ['ca-cert-file'])
-    cert_path = os.path.join(AUTH_DIR, cert_file)
-    cert = None
-
-    if os.path.isfile(cert_path):
-        if not os.access(cert_path, os.R_OK):
-            run(f'sudo chmod 644 {cert_path}')
-
-        with open(cert_path, 'r') as f:
-            cert_data = f.read()
-            cert = load_certificate(cert_data, wrap_tags=False)
-
-    if cert:
-        cert_pem = encode_certificate(cert)
-        config.set(pki_base + ['ca', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
-        config.set(x509_base + ['ca-certificate'], value=pki_name)
-    else:
-        print(f'Failed to migrate CA certificate on sstp config')
-
-    config.delete(x509_base + ['ca-cert-file'])
-
-if config.exists(x509_base + ['cert-file']):
-    cert_file = config.return_value(x509_base + ['cert-file'])
-    cert_path = os.path.join(AUTH_DIR, cert_file)
-    cert = None
-
-    if os.path.isfile(cert_path):
-        if not os.access(cert_path, os.R_OK):
-            run(f'sudo chmod 644 {cert_path}')
-
-        with open(cert_path, 'r') as f:
-            cert_data = f.read()
-            cert = load_certificate(cert_data, wrap_tags=False)
-
-    if cert:
-        cert_pem = encode_certificate(cert)
-        config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
-        config.set(x509_base + ['certificate'], value=pki_name)
-    else:
-        print(f'Failed to migrate certificate on sstp config')
-
-    config.delete(x509_base + ['cert-file'])
-
-if config.exists(x509_base + ['key-file']):
-    key_file = config.return_value(x509_base + ['key-file'])
-    key_path = os.path.join(AUTH_DIR, key_file)
-    key = None
-
-    if os.path.isfile(key_path):
-        if not os.access(key_path, os.R_OK):
-            run(f'sudo chmod 644 {key_path}')
-
-        with open(key_path, 'r') as f:
-            key_data = f.read()
-            key = load_private_key(key_data, passphrase=None, wrap_tags=False)
-
-    if key:
-        key_pem = encode_private_key(key, passphrase=None)
-        config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
-    else:
-        print(f'Failed to migrate private key on sstp config')
-
-    config.delete(x509_base + ['key-file'])
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
+
+    if not config.exists(base + ['ssl']):
+        return
+
+    x509_base = base + ['ssl']
+    pki_name = 'sstp'
+
+    if not config.exists(pki_base + ['ca']):
+        config.set(pki_base + ['ca'])
+        config.set_tag(pki_base + ['ca'])
+
+    if not config.exists(pki_base + ['certificate']):
+        config.set(pki_base + ['certificate'])
+        config.set_tag(pki_base + ['certificate'])
+
+    if config.exists(x509_base + ['ca-cert-file']):
+        cert_file = config.return_value(x509_base + ['ca-cert-file'])
+        cert_path = os.path.join(AUTH_DIR, cert_file)
+        cert = None
+
+        if os.path.isfile(cert_path):
+            if not os.access(cert_path, os.R_OK):
+                run(f'sudo chmod 644 {cert_path}')
+
+            with open(cert_path, 'r') as f:
+                cert_data = f.read()
+                cert = load_certificate(cert_data, wrap_tags=False)
+
+        if cert:
+            cert_pem = encode_certificate(cert)
+            config.set(pki_base + ['ca', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+            config.set(x509_base + ['ca-certificate'], value=pki_name)
+        else:
+            print(f'Failed to migrate CA certificate on sstp config')
+
+        config.delete(x509_base + ['ca-cert-file'])
+
+    if config.exists(x509_base + ['cert-file']):
+        cert_file = config.return_value(x509_base + ['cert-file'])
+        cert_path = os.path.join(AUTH_DIR, cert_file)
+        cert = None
+
+        if os.path.isfile(cert_path):
+            if not os.access(cert_path, os.R_OK):
+                run(f'sudo chmod 644 {cert_path}')
+
+            with open(cert_path, 'r') as f:
+                cert_data = f.read()
+                cert = load_certificate(cert_data, wrap_tags=False)
+
+        if cert:
+            cert_pem = encode_certificate(cert)
+            config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem))
+            config.set(x509_base + ['certificate'], value=pki_name)
+        else:
+            print(f'Failed to migrate certificate on sstp config')
+
+        config.delete(x509_base + ['cert-file'])
+
+    if config.exists(x509_base + ['key-file']):
+        key_file = config.return_value(x509_base + ['key-file'])
+        key_path = os.path.join(AUTH_DIR, key_file)
+        key = None
+
+        if os.path.isfile(key_path):
+            if not os.access(key_path, os.R_OK):
+                run(f'sudo chmod 644 {key_path}')
+
+            with open(key_path, 'r') as f:
+                key_data = f.read()
+                key = load_private_key(key_data, passphrase=None, wrap_tags=False)
+
+        if key:
+            key_pem = encode_private_key(key, passphrase=None)
+            config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem))
+        else:
+            print(f'Failed to migrate private key on sstp config')
+
+        config.delete(x509_base + ['key-file'])
diff --git a/src/migration-scripts/sstp/4-to-5 b/src/migration-scripts/sstp/4-to-5
old mode 100755
new mode 100644
index 6907240a0..254e828af
--- a/src/migration-scripts/sstp/4-to-5
+++ b/src/migration-scripts/sstp/4-to-5
@@ -1,59 +1,41 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - move all pool to named pools
 #       'subnet' migrate to namedpool 'default-subnet-pool'
 #       'default-subnet-pool' is the next pool for 'default-range-pool'
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base = ['vpn', 'sstp']
 pool_base = base + ['client-ip-pool']
-if not config.exists(base):
-    exit(0)
 
-if not config.exists(pool_base):
-    exit(0)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
 
-range_pool_name = 'default-range-pool'
+    if not config.exists(pool_base):
+        return
 
-if config.exists(pool_base + ['subnet']):
-    default_pool = range_pool_name
-    for subnet in config.return_values(pool_base + ['subnet']):
-        config.set(pool_base + [range_pool_name, 'range'], value=subnet, replace=False)
-    config.delete(pool_base + ['subnet'])
-    config.set(base + ['default-pool'], value=default_pool)
-# format as tag node
-config.set_tag(pool_base)
+    range_pool_name = 'default-range-pool'
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    if config.exists(pool_base + ['subnet']):
+        default_pool = range_pool_name
+        for subnet in config.return_values(pool_base + ['subnet']):
+            config.set(pool_base + [range_pool_name, 'range'], value=subnet, replace=False)
+        config.delete(pool_base + ['subnet'])
+        config.set(base + ['default-pool'], value=default_pool)
+    # format as tag node
+    config.set_tag(pool_base)
diff --git a/src/migration-scripts/sstp/5-to-6 b/src/migration-scripts/sstp/5-to-6
old mode 100755
new mode 100644
index 43b99044d..fc3cc29b2
--- a/src/migration-scripts/sstp/5-to-6
+++ b/src/migration-scripts/sstp/5-to-6
@@ -1,58 +1,40 @@
-#!/usr/bin/env python3
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Migrating to named ipv6 pools
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
+base = ['vpn', 'sstp']
+pool_base = base + ['client-ipv6-pool']
 
-file_name = argv[1]
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+    if not config.exists(pool_base):
+        return
 
-config = ConfigTree(config_file)
-base = ['vpn', 'sstp']
-pool_base = base + ['client-ipv6-pool']
-if not config.exists(base):
-    exit(0)
-
-if not config.exists(pool_base):
-    exit(0)
-
-ipv6_pool_name = 'ipv6-pool'
-config.copy(pool_base, pool_base + [ipv6_pool_name])
-
-if config.exists(pool_base + ['prefix']):
-    config.delete(pool_base + ['prefix'])
-    config.set(base + ['default-ipv6-pool'], value=ipv6_pool_name)
-if config.exists(pool_base + ['delegate']):
-    config.delete(pool_base + ['delegate'])
-
-# format as tag node
-config.set_tag(pool_base)
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    ipv6_pool_name = 'ipv6-pool'
+    config.copy(pool_base, pool_base + [ipv6_pool_name])
+
+    if config.exists(pool_base + ['prefix']):
+        config.delete(pool_base + ['prefix'])
+        config.set(base + ['default-ipv6-pool'], value=ipv6_pool_name)
+    if config.exists(pool_base + ['delegate']):
+        config.delete(pool_base + ['delegate'])
+
+    # format as tag node
+    config.set_tag(pool_base)
diff --git a/src/migration-scripts/system/10-to-11 b/src/migration-scripts/system/10-to-11
old mode 100755
new mode 100644
index 5d662af40..76d7f23cb
--- a/src/migration-scripts/system/10-to-11
+++ b/src/migration-scripts/system/10-to-11
@@ -1,36 +1,32 @@
-#!/usr/bin/env python3
+# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Operator accounts have been deprecated due to a security issue. Those accounts
 # will be converted to regular admin accounts.
 
-import sys
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
 base_level = ['system', 'login', 'user']
 
-if not config.exists(base_level):
-    # Nothing to do, which shouldn't happen anyway
-    # only if you wipe the config and reboot.
-    sys.exit(0)
-else:
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base_level):
+        # Nothing to do, which shouldn't happen anyway
+        # only if you wipe the config and reboot.
+        return
+
     for user in config.list_nodes(base_level):
         if config.exists(base_level + [user, 'level']):
             if config.return_value(base_level + [user, 'level']) == 'operator':
                 config.set(base_level + [user, 'level'], value="admin", replace=True)
-
-    try:
-        open(file_name,'w').write(config.to_string())
-
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/system/11-to-12 b/src/migration-scripts/system/11-to-12
old mode 100755
new mode 100644
index 880ab56dc..71c359b7e
--- a/src/migration-scripts/system/11-to-12
+++ b/src/migration-scripts/system/11-to-12
@@ -1,72 +1,69 @@
-#!/usr/bin/env python3
+# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Unclutter RADIUS configuration
 #
 # Move radius-server top level tag nodes to a regular node which allows us
 # to specify additional general features for the RADIUS client.
 
-import sys
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
+cfg_base = ['system', 'login']
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not (config.exists(cfg_base + ['radius-server']) or config.exists(cfg_base + ['radius-source-address'])):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-cfg_base = ['system', 'login']
-if not (config.exists(cfg_base + ['radius-server']) or config.exists(cfg_base + ['radius-source-address'])):
-    # Nothing to do
-    sys.exit(0)
-else:
     #
     # Migrate "system login radius-source-address" to "system login radius"
     #
     if config.exists(cfg_base + ['radius-source-address']):
         address = config.return_value(cfg_base + ['radius-source-address'])
         # delete old configuration node
         config.delete(cfg_base + ['radius-source-address'])
         # write new configuration node
         config.set(cfg_base + ['radius', 'source-address'], value=address)
 
     #
     # Migrate "system login radius-server" tag node to new
     # "system login radius server" tag node and also rename the "secret" node to "key"
     #
     if config.exists(cfg_base + ['radius-server']):
         for server in config.list_nodes(cfg_base + ['radius-server']):
             base_server = cfg_base + ['radius-server', server]
             # "key" node is mandatory
             key = config.return_value(base_server + ['secret'])
             config.set(cfg_base + ['radius', 'server', server, 'key'], value=key)
 
             # "port" is optional
             if config.exists(base_server + ['port']):
                 port = config.return_value(base_server + ['port'])
                 config.set(cfg_base + ['radius', 'server', server, 'port'], value=port)
 
             # "timeout is optional"
             if config.exists(base_server + ['timeout']):
                 timeout = config.return_value(base_server + ['timeout'])
                 config.set(cfg_base + ['radius', 'server', server, 'timeout'], value=timeout)
 
             # format as tag node
             config.set_tag(cfg_base + ['radius', 'server'])
 
             # delete old configuration node
             config.delete(base_server)
 
         # delete top level tag node
         if config.exists(cfg_base + ['radius-server']):
             config.delete(cfg_base + ['radius-server'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/system/12-to-13 b/src/migration-scripts/system/12-to-13
old mode 100755
new mode 100644
index e6c4e3802..014edba91
--- a/src/migration-scripts/system/12-to-13
+++ b/src/migration-scripts/system/12-to-13
@@ -1,47 +1,44 @@
-#!/usr/bin/env python3
+# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # converts 'set system syslog host <address>:<port>'
 # to 'set system syslog host <address> port <port>'
 
-import sys
 import re
 
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-  print("Must specify file name!")
-  sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-  config_file = f.read()
-
-config = ConfigTree(config_file)
 cbase = ['system', 'syslog', 'host']
 
-if not config.exists(cbase):
-    sys.exit(0)
-
-for host in config.list_nodes(cbase):
-    if re.search(':[0-9]{1,5}$',host):
-        h = re.search('^[a-zA-Z\-0-9\.]+', host).group(0)
-        p = re.sub(':', '', re.search(':[0-9]+$', host).group(0))
-        config.set(cbase + [h])
-        config.set(cbase + [h, 'port'], value=p)
-        for fac in config.list_nodes(cbase + [host, 'facility']):
-            config.set(cbase + [h, 'facility', fac])
-            config.set_tag(cbase + [h, 'facility'])
-            if config.exists(cbase + [host, 'facility', fac, 'protocol']):
-                proto = config.return_value(cbase + [host, 'facility', fac, 'protocol'])
-                config.set(cbase + [h, 'facility', fac, 'protocol'], value=proto)
-            if config.exists(cbase + [host, 'facility', fac, 'level']):
-                lvl = config.return_value(cbase + [host, 'facility', fac, 'level'])
-                config.set(cbase + [h, 'facility', fac, 'level'], value=lvl)
-        config.delete(cbase + [host])
-
-try:
-    open(file_name,'w').write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(cbase):
+        return
+
+    for host in config.list_nodes(cbase):
+        if re.search(':[0-9]{1,5}$',host):
+            h = re.search('^[a-zA-Z\-0-9\.]+', host).group(0)
+            p = re.sub(':', '', re.search(':[0-9]+$', host).group(0))
+            config.set(cbase + [h])
+            config.set(cbase + [h, 'port'], value=p)
+            for fac in config.list_nodes(cbase + [host, 'facility']):
+                config.set(cbase + [h, 'facility', fac])
+                config.set_tag(cbase + [h, 'facility'])
+                if config.exists(cbase + [host, 'facility', fac, 'protocol']):
+                    proto = config.return_value(cbase + [host, 'facility', fac, 'protocol'])
+                    config.set(cbase + [h, 'facility', fac, 'protocol'], value=proto)
+                if config.exists(cbase + [host, 'facility', fac, 'level']):
+                    lvl = config.return_value(cbase + [host, 'facility', fac, 'level'])
+                    config.set(cbase + [h, 'facility', fac, 'level'], value=lvl)
+            config.delete(cbase + [host])
diff --git a/src/migration-scripts/system/13-to-14 b/src/migration-scripts/system/13-to-14
old mode 100755
new mode 100644
index 5b781158b..fbbecbcd3
--- a/src/migration-scripts/system/13-to-14
+++ b/src/migration-scripts/system/13-to-14
@@ -1,70 +1,67 @@
-#!/usr/bin/env python3
+# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Fixup non existent time-zones. Some systems have time-zone set to: Los*
 # (Los_Angeles), Den* (Denver), New* (New_York) ... but those are no real IANA
 # assigned time zones. In the past they have been silently remapped.
 #
 # Time to clean it up!
 #
 # Migrate all configured timezones to real IANA assigned timezones!
 
 import re
-import sys
 
 from vyos.configtree import ConfigTree
 from vyos.utils.process import cmd
 
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
+tz_base = ['system', 'time-zone']
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(tz_base):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-tz_base = ['system', 'time-zone']
-if not config.exists(tz_base):
-    # Nothing to do
-    sys.exit(0)
-else:
     tz = config.return_value(tz_base)
 
     # retrieve all valid timezones
     try:
         tz_datas = cmd('timedatectl list-timezones')
     except OSError:
         tz_datas = ''
     tz_data = tz_datas.split('\n')
 
     if re.match(r'[Ll][Oo][Ss].+', tz):
         tz = 'America/Los_Angeles'
     elif re.match(r'[Dd][Ee][Nn].+', tz):
         tz = 'America/Denver'
     elif re.match(r'[Hh][Oo][Nn][Oo].+', tz):
         tz = 'Pacific/Honolulu'
     elif re.match(r'[Nn][Ee][Ww].+', tz):
         tz = 'America/New_York'
     elif re.match(r'[Cc][Hh][Ii][Cc]*.+', tz):
         tz = 'America/Chicago'
     elif re.match(r'[Aa][Nn][Cc].+', tz):
         tz = 'America/Anchorage'
     elif re.match(r'[Pp][Hh][Oo].+', tz):
         tz = 'America/Phoenix'
     elif re.match(r'GMT(.+)?', tz):
         tz = 'Etc/' + tz
     elif tz not in tz_data:
         # assign default UTC timezone
         tz = 'UTC'
 
     # replace timezone data is required
     config.set(tz_base, value=tz)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/system/14-to-15 b/src/migration-scripts/system/14-to-15
old mode 100755
new mode 100644
index feaac37de..281809460
--- a/src/migration-scripts/system/14-to-15
+++ b/src/migration-scripts/system/14-to-15
@@ -1,40 +1,37 @@
-#!/usr/bin/env python3
+# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
+
 # Delete 'system ipv6 blacklist' option as the IPv6 module can no longer be
 # blacklisted as it is required by e.g. WireGuard and thus will always be
 # loaded.
 
 import os
-import sys
 
 ipv6_blacklist_file = '/etc/modprobe.d/vyatta_blacklist_ipv6.conf'
 
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
+ip_base = ['system', 'ipv6']
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(ip_base):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-ip_base = ['system', 'ipv6']
-if not config.exists(ip_base):
-    # Nothing to do
-    sys.exit(0)
-else:
     # delete 'system ipv6 blacklist' node
     if config.exists(ip_base + ['blacklist']):
         config.delete(ip_base + ['blacklist'])
         if os.path.isfile(ipv6_blacklist_file):
             os.unlink(ipv6_blacklist_file)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/system/15-to-16 b/src/migration-scripts/system/15-to-16
old mode 100755
new mode 100644
index 2944cdb1e..7db042930
--- a/src/migration-scripts/system/15-to-16
+++ b/src/migration-scripts/system/15-to-16
@@ -1,36 +1,32 @@
-#!/usr/bin/env python3
+# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Make 'system options reboot-on-panic' valueless
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-import sys
+# Make 'system options reboot-on-panic' valueless
 
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
+base = ['system', 'options']
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-base = ['system', 'options']
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
-else:
     if config.exists(base + ['reboot-on-panic']):
         reboot = config.return_value(base + ['reboot-on-panic'])
         config.delete(base + ['reboot-on-panic'])
         # create new valueless node if action was true
         if reboot == "true":
             config.set(base + ['reboot-on-panic'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/system/16-to-17 b/src/migration-scripts/system/16-to-17
old mode 100755
new mode 100644
index afa171a9b..9fb86af88
--- a/src/migration-scripts/system/16-to-17
+++ b/src/migration-scripts/system/16-to-17
@@ -1,54 +1,36 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # * remove "system login user <user> group" node, Why should be add a user to a
 #   3rd party group when the system is fully managed by CLI?
 # * remove "system login user <user> level" node
 #   This is the only privilege level left and also the default, what is the
 #   sense in keeping this orphaned node?
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
+base = ['system', 'login', 'user']
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-base = ['system', 'login', 'user']
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
-else:
     for user in config.list_nodes(base):
         if config.exists(base + [user, 'group']):
             config.delete(base + [user, 'group'])
 
         if config.exists(base + [user, 'level']):
             config.delete(base + [user, 'level'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/system/17-to-18 b/src/migration-scripts/system/17-to-18
old mode 100755
new mode 100644
index f6adebb06..323ef4e65
--- a/src/migration-scripts/system/17-to-18
+++ b/src/migration-scripts/system/17-to-18
@@ -1,76 +1,59 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # remove "system console netconsole"
 # remove "system console device <device> modem"
 
 import os
-import sys
 
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
+base = ['system', 'console']
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-base = ['system', 'console']
-if not config.exists(base):
-    # Nothing to do
-    sys.exit(0)
-else:
     # remove "system console netconsole" (T2561)
     if config.exists(base + ['netconsole']):
         config.delete(base + ['netconsole'])
 
     if config.exists(base + ['device']):
         for device in config.list_nodes(base + ['device']):
             dev_path = base + ['device', device]
             # remove "system console device <device> modem" (T2570)
             if config.exists(dev_path + ['modem']):
                 config.delete(dev_path + ['modem'])
 
             # Only continue on USB based serial consoles
             if not 'ttyUSB' in device:
                 continue
 
             # A serial console has been configured but it does no longer
             # exist on the system - cleanup
             if not os.path.exists(f'/dev/{device}'):
                 config.delete(dev_path)
                 continue
 
             # migrate from ttyUSB device to new device in /dev/serial/by-bus
             for root, dirs, files in os.walk('/dev/serial/by-bus'):
                 for usb_device in files:
                     device_file = os.path.realpath(os.path.join(root, usb_device))
                     # migrate to new USB device names (T2529)
                     if os.path.basename(device_file) == device:
                         config.copy(dev_path, base + ['device', usb_device])
                         # Delete old USB node from config
                         config.delete(dev_path)
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/system/18-to-19 b/src/migration-scripts/system/18-to-19
old mode 100755
new mode 100644
index fad1d17a4..5d9788d70
--- a/src/migration-scripts/system/18-to-19
+++ b/src/migration-scripts/system/18-to-19
@@ -1,102 +1,81 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # migrate disable-dhcp-nameservers (boolean) to name-servers-dhcp <interface>
 # if disable-dhcp-nameservers is set, just remove it
 # else retrieve all interface names that have configured dhcp(v6) address and
 # add them to the new name-servers-dhcp node
 
-from sys import argv, exit
 from vyos.ifconfig import Interface
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 base = ['system']
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-if config.exists(base + ['disable-dhcp-nameservers']):
-    config.delete(base + ['disable-dhcp-nameservers'])
-else:
-    dhcp_interfaces = []
-
-    # go through all interfaces searching for 'address dhcp(v6)?'
-    for sect in Interface.sections():
-        sect_base = ['interfaces', sect]
-
-        if not config.exists(sect_base):
-            continue
 
-        for intf in config.list_nodes(sect_base):
-            intf_base = sect_base + [intf]
-
-            # try without vlans
-            if config.exists(intf_base + ['address']):
-                for addr in config.return_values(intf_base + ['address']):
-                    if addr in ['dhcp', 'dhcpv6']:
-                        dhcp_interfaces.append(intf)
-
-            # try vif
-            if config.exists(intf_base + ['vif']):
-                for vif in config.list_nodes(intf_base + ['vif']):
-                    vif_base = intf_base + ['vif', vif]
-                    if config.exists(vif_base + ['address']):
-                        for addr in config.return_values(vif_base + ['address']):
-                            if addr in ['dhcp', 'dhcpv6']:
-                                dhcp_interfaces.append(f'{intf}.{vif}')
-
-            # try vif-s
-            if config.exists(intf_base + ['vif-s']):
-                for vif_s in config.list_nodes(intf_base + ['vif-s']):
-                    vif_s_base = intf_base + ['vif-s', vif_s]
-                    if config.exists(vif_s_base + ['address']):
-                        for addr in config.return_values(vif_s_base + ['address']):
-                            if addr in ['dhcp', 'dhcpv6']:
-                                dhcp_interfaces.append(f'{intf}.{vif_s}')
-
-                # try vif-c
-                if config.exists(intf_base + ['vif-c']):
-                    for vif_c in config.list_nodes(vif_s_base + ['vif-c']):
-                        vif_c_base = vif_s_base + ['vif-c', vif_c]
-                        if config.exists(vif_c_base + ['address']):
-                            for addr in config.return_values(vif_c_base + ['address']):
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    if config.exists(base + ['disable-dhcp-nameservers']):
+        config.delete(base + ['disable-dhcp-nameservers'])
+    else:
+        dhcp_interfaces = []
+
+        # go through all interfaces searching for 'address dhcp(v6)?'
+        for sect in Interface.sections():
+            sect_base = ['interfaces', sect]
+
+            if not config.exists(sect_base):
+                continue
+
+            for intf in config.list_nodes(sect_base):
+                intf_base = sect_base + [intf]
+
+                # try without vlans
+                if config.exists(intf_base + ['address']):
+                    for addr in config.return_values(intf_base + ['address']):
+                        if addr in ['dhcp', 'dhcpv6']:
+                            dhcp_interfaces.append(intf)
+
+                # try vif
+                if config.exists(intf_base + ['vif']):
+                    for vif in config.list_nodes(intf_base + ['vif']):
+                        vif_base = intf_base + ['vif', vif]
+                        if config.exists(vif_base + ['address']):
+                            for addr in config.return_values(vif_base + ['address']):
                                 if addr in ['dhcp', 'dhcpv6']:
-                                    dhcp_interfaces.append(f'{intf}.{vif_s}.{vif_c}')
-
-    # set new config nodes
-    for intf in dhcp_interfaces:
-        config.set(base + ['name-servers-dhcp'], value=intf, replace=False)
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
-
-exit(0)
+                                    dhcp_interfaces.append(f'{intf}.{vif}')
+
+                # try vif-s
+                if config.exists(intf_base + ['vif-s']):
+                    for vif_s in config.list_nodes(intf_base + ['vif-s']):
+                        vif_s_base = intf_base + ['vif-s', vif_s]
+                        if config.exists(vif_s_base + ['address']):
+                            for addr in config.return_values(vif_s_base + ['address']):
+                                if addr in ['dhcp', 'dhcpv6']:
+                                    dhcp_interfaces.append(f'{intf}.{vif_s}')
+
+                    # try vif-c
+                    if config.exists(intf_base + ['vif-c']):
+                        for vif_c in config.list_nodes(vif_s_base + ['vif-c']):
+                            vif_c_base = vif_s_base + ['vif-c', vif_c]
+                            if config.exists(vif_c_base + ['address']):
+                                for addr in config.return_values(vif_c_base + ['address']):
+                                    if addr in ['dhcp', 'dhcpv6']:
+                                        dhcp_interfaces.append(f'{intf}.{vif_s}.{vif_c}')
+
+        # set new config nodes
+        for intf in dhcp_interfaces:
+            config.set(base + ['name-servers-dhcp'], value=intf, replace=False)
diff --git a/src/migration-scripts/system/19-to-20 b/src/migration-scripts/system/19-to-20
old mode 100755
new mode 100644
index 177173c50..cb84e11fc
--- a/src/migration-scripts/system/19-to-20
+++ b/src/migration-scripts/system/19-to-20
@@ -1,62 +1,44 @@
-#!/usr/bin/env python3
+# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2020-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3048: remove smp-affinity node from ethernet and use tuned instead
 
-from sys import exit, argv
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['system', 'options']
 base_new = ['system', 'option']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-if config.exists(base_new):
-    for node in config.list_nodes(base):
-        config.copy(base + [node], base_new + [node])
-else:
-    config.copy(base, base_new)
 
-config.delete(base)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-# Rename "system option beep-if-fully-booted" -> "system option startup-beep"
-base_beep = base_new + ['beep-if-fully-booted']
-if config.exists(base_beep):
-    config.rename(base_beep, 'startup-beep')
+    if config.exists(base_new):
+        for node in config.list_nodes(base):
+            config.copy(base + [node], base_new + [node])
+    else:
+        config.copy(base, base_new)
 
-# Rename "system option ctrl-alt-del-action" -> "system option ctrl-alt-delete"
-base_ctrl_alt_del = base_new + ['ctrl-alt-del-action']
-if config.exists(base_ctrl_alt_del):
-    config.rename(base_ctrl_alt_del, 'ctrl-alt-delete')
+    config.delete(base)
 
+    # Rename "system option beep-if-fully-booted" -> "system option startup-beep"
+    base_beep = base_new + ['beep-if-fully-booted']
+    if config.exists(base_beep):
+        config.rename(base_beep, 'startup-beep')
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    # Rename "system option ctrl-alt-del-action" -> "system option ctrl-alt-delete"
+    base_ctrl_alt_del = base_new + ['ctrl-alt-del-action']
+    if config.exists(base_ctrl_alt_del):
+        config.rename(base_ctrl_alt_del, 'ctrl-alt-delete')
diff --git a/src/migration-scripts/system/20-to-21 b/src/migration-scripts/system/20-to-21
old mode 100755
new mode 100644
index 24e042ce2..71c283da6
--- a/src/migration-scripts/system/20-to-21
+++ b/src/migration-scripts/system/20-to-21
@@ -1,46 +1,30 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3795: merge "system name-servers-dhcp" into "system name-server"
 
-from sys import argv
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['system', 'name-servers-dhcp']
-config = ConfigTree(config_file)
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
 
-for interface in config.return_values(base):
-    config.set(['system', 'name-server'], value=interface, replace=False)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-config.delete(base)
+    for interface in config.return_values(base):
+        config.set(['system', 'name-server'], value=interface, replace=False)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    config.delete(base)
diff --git a/src/migration-scripts/system/21-to-22 b/src/migration-scripts/system/21-to-22
old mode 100755
new mode 100644
index 2a1b603c6..0e68a6856
--- a/src/migration-scripts/system/21-to-22
+++ b/src/migration-scripts/system/21-to-22
@@ -1,55 +1,38 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-from sys import exit, argv
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['system', 'sysctl']
-config = ConfigTree(config_file)
 
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-for all_custom in ['all', 'custom']:
-    if config.exists(base + [all_custom]):
-        for key in config.list_nodes(base + [all_custom]):
-            tmp = config.return_value(base + [all_custom, key, 'value'])
-            config.set(base + ['parameter', key, 'value'], value=tmp)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    for all_custom in ['all', 'custom']:
+        if config.exists(base + [all_custom]):
+            for key in config.list_nodes(base + [all_custom]):
+                tmp = config.return_value(base + [all_custom, key, 'value'])
+                config.set(base + ['parameter', key, 'value'], value=tmp)
+                config.set_tag(base + ['parameter'])
+            config.delete(base + [all_custom])
+
+    for ipv4_param in ['net.ipv4.igmp_max_memberships', 'net.ipv4.ipfrag_time']:
+        if config.exists(base + [ipv4_param]):
+            tmp = config.return_value(base + [ipv4_param])
+            config.set(base + ['parameter', ipv4_param, 'value'], value=tmp)
             config.set_tag(base + ['parameter'])
-        config.delete(base + [all_custom])
-
-for ipv4_param in ['net.ipv4.igmp_max_memberships', 'net.ipv4.ipfrag_time']:
-    if config.exists(base + [ipv4_param]):
-        tmp = config.return_value(base + [ipv4_param])
-        config.set(base + ['parameter', ipv4_param, 'value'], value=tmp)
-        config.set_tag(base + ['parameter'])
-        config.delete(base + [ipv4_param])
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+            config.delete(base + [ipv4_param])
diff --git a/src/migration-scripts/system/22-to-23 b/src/migration-scripts/system/22-to-23
old mode 100755
new mode 100644
index f83279b88..e49094e4a
--- a/src/migration-scripts/system/22-to-23
+++ b/src/migration-scripts/system/22-to-23
@@ -1,48 +1,31 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-from sys import exit, argv
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['system', 'ipv6']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
 
-# T4346: drop support to disbale IPv6 address family within the OS Kernel
-if config.exists(base + ['disable']):
-    config.delete(base + ['disable'])
-    # IPv6 address family disable was the only CLI option set - we can cleanup
-    # the entire tree
-    if len(config.list_nodes(base)) == 0:
-        config.delete(base)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+    # T4346: drop support to disbale IPv6 address family within the OS Kernel
+    if config.exists(base + ['disable']):
+        config.delete(base + ['disable'])
+        # IPv6 address family disable was the only CLI option set - we can cleanup
+        # the entire tree
+        if len(config.list_nodes(base)) == 0:
+            config.delete(base)
diff --git a/src/migration-scripts/system/23-to-24 b/src/migration-scripts/system/23-to-24
old mode 100755
new mode 100644
index 1fd61d83b..feb62bc32
--- a/src/migration-scripts/system/23-to-24
+++ b/src/migration-scripts/system/23-to-24
@@ -1,87 +1,71 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022-2024 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 from ipaddress import ip_interface
 from ipaddress import ip_address
-from sys import exit, argv
+
 from vyos.configtree import ConfigTree
 from vyos.template import is_ipv4
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['protocols', 'static', 'arp']
 tmp_base = ['protocols', 'static', 'arp-tmp']
-config = ConfigTree(config_file)
 
-def fixup_cli(config, path, interface):
+def fixup_cli(config, path, interface, host):
     if config.exists(path + ['address']):
         for address in config.return_values(path + ['address']):
             tmp = ip_interface(address)
             # ARP is only available for IPv4 ;-)
             if not is_ipv4(tmp):
                 continue
             if ip_address(host) in tmp.network.hosts():
                 mac = config.return_value(tmp_base + [host, 'hwaddr'])
                 iface_path = ['protocols', 'static', 'arp', 'interface']
                 config.set(iface_path + [interface, 'address', host, 'mac'], value=mac)
                 config.set_tag(iface_path)
                 config.set_tag(iface_path + [interface, 'address'])
                 continue
 
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-# We need a temporary copy of the config tree as the original one needs to be
-# deleted first due to a change iun thge tagNode structure.
-config.copy(base, tmp_base)
-config.delete(base)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-for host in config.list_nodes(tmp_base):
-    for type in config.list_nodes(['interfaces']):
-        for interface in config.list_nodes(['interfaces', type]):
-            if_base = ['interfaces', type, interface]
-            fixup_cli(config, if_base, interface)
+    # We need a temporary copy of the config tree as the original one needs to be
+    # deleted first due to a change iun thge tagNode structure.
+    config.copy(base, tmp_base)
+    config.delete(base)
 
-            if config.exists(if_base + ['vif']):
-                for vif in config.list_nodes(if_base + ['vif']):
-                    vif_base = ['interfaces', type, interface, 'vif', vif]
-                    fixup_cli(config, vif_base, f'{interface}.{vif}')
+    for host in config.list_nodes(tmp_base):
+        for type in config.list_nodes(['interfaces']):
+            for interface in config.list_nodes(['interfaces', type]):
+                if_base = ['interfaces', type, interface]
+                fixup_cli(config, if_base, interface, host)
 
-            if config.exists(if_base + ['vif-s']):
-                for vif_s in config.list_nodes(if_base + ['vif-s']):
-                    vif_s_base = ['interfaces', type, interface, 'vif-s', vif_s]
-                    fixup_cli(config, vif_s_base, f'{interface}.{vif_s}')
+                if config.exists(if_base + ['vif']):
+                    for vif in config.list_nodes(if_base + ['vif']):
+                        vif_base = ['interfaces', type, interface, 'vif', vif]
+                        fixup_cli(config, vif_base, f'{interface}.{vif}', host)
 
-                    if config.exists(if_base + ['vif-s', vif_s, 'vif-c']):
-                        for vif_c in config.list_nodes(if_base + ['vif-s', vif_s, 'vif-c']):
-                            vif_c_base = ['interfaces', type, interface, 'vif-s', vif_s, 'vif-c', vif_c]
-                            fixup_cli(config, vif_c_base, f'{interface}.{vif_s}.{vif_c}')
+                if config.exists(if_base + ['vif-s']):
+                    for vif_s in config.list_nodes(if_base + ['vif-s']):
+                        vif_s_base = ['interfaces', type, interface, 'vif-s', vif_s]
+                        fixup_cli(config, vif_s_base, f'{interface}.{vif_s}', host)
 
-config.delete(tmp_base)
+                        if config.exists(if_base + ['vif-s', vif_s, 'vif-c']):
+                            for vif_c in config.list_nodes(if_base + ['vif-s', vif_s, 'vif-c']):
+                                vif_c_base = ['interfaces', type, interface, 'vif-s', vif_s, 'vif-c', vif_c]
+                                fixup_cli(config, vif_c_base, f'{interface}.{vif_s}.{vif_c}', host)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+    config.delete(tmp_base)
diff --git a/src/migration-scripts/system/24-to-25 b/src/migration-scripts/system/24-to-25
old mode 100755
new mode 100644
index 1c81a76e7..bdb89902e
--- a/src/migration-scripts/system/24-to-25
+++ b/src/migration-scripts/system/24-to-25
@@ -1,52 +1,35 @@
-#!/usr/bin/env python3
+# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2022 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
+
 # Migrate system syslog global archive to system logs logrotate messages
 
-from sys import exit, argv
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['system', 'syslog', 'global', 'archive']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    exit(0)
 
-if config.exists(base + ['file']):
-    tmp = config.return_value(base + ['file'])
-    config.set(['system', 'logs', 'logrotate', 'messages', 'rotate'], value=tmp)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
 
-if config.exists(base + ['size']):
-    tmp = config.return_value(base + ['size'])
-    tmp = max(round(int(tmp) / 1024), 1) # kb -> mb
-    config.set(['system', 'logs', 'logrotate', 'messages', 'max-size'], value=tmp)
+    if config.exists(base + ['file']):
+        tmp = config.return_value(base + ['file'])
+        config.set(['system', 'logs', 'logrotate', 'messages', 'rotate'], value=tmp)
 
-config.delete(base)
+    if config.exists(base + ['size']):
+        tmp = config.return_value(base + ['size'])
+        tmp = max(round(int(tmp) / 1024), 1) # kb -> mb
+        config.set(['system', 'logs', 'logrotate', 'messages', 'max-size'], value=tmp)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+    config.delete(base)
diff --git a/src/migration-scripts/system/25-to-26 b/src/migration-scripts/system/25-to-26
old mode 100755
new mode 100644
index 7bdf3be98..8832f48e5
--- a/src/migration-scripts/system/25-to-26
+++ b/src/migration-scripts/system/25-to-26
@@ -1,82 +1,65 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
+
 # syslog: migrate deprecated CLI options
 #         - protocols -> local7
 #         - security -> auth
 
-from sys import exit, argv
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['system', 'syslog']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    exit(0)
 
 def rename_facilities(config, base_tree, facility, facility_new) -> None:
     if config.exists(base + [base_tree, 'facility', facility]):
         # do not overwrite already existing replacement facility
         if not config.exists(base + [base_tree, 'facility', facility_new]):
             config.rename(base + [base_tree, 'facility', facility], facility_new)
         else:
             # delete old duplicate facility config
             config.delete(base + [base_tree, 'facility', facility])
 
-#
-# Rename protocols and securityy facility to common ones
-#
-replace = {
-    'protocols' : 'local7',
-    'security' : 'auth'
-}
-for facility, facility_new in replace.items():
-    rename_facilities(config, 'console', facility, facility_new)
-    rename_facilities(config, 'global', facility, facility_new)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
 
-    if config.exists(base + ['host']):
-        for host in config.list_nodes(base + ['host']):
-            rename_facilities(config, f'host {host}', facility, facility_new)
+    #
+    # Rename protocols and securityy facility to common ones
+    #
+    replace = {
+        'protocols' : 'local7',
+        'security' : 'auth'
+    }
+    for facility, facility_new in replace.items():
+        rename_facilities(config, 'console', facility, facility_new)
+        rename_facilities(config, 'global', facility, facility_new)
 
-#
-# It makes no sense to configure udp/tcp transport per individual facility
-#
-if config.exists(base + ['host']):
-    for host in config.list_nodes(base + ['host']):
-        protocol = None
-        for facility in config.list_nodes(base + ['host', host, 'facility']):
-            tmp_path = base + ['host', host, 'facility', facility, 'protocol']
-            if config.exists(tmp_path):
-                # We can only change the first one
-                if protocol == None:
-                    protocol = config.return_value(tmp_path)
-                    config.set(base + ['host', host, 'protocol'], value=protocol)
-                config.delete(tmp_path)
+        if config.exists(base + ['host']):
+            for host in config.list_nodes(base + ['host']):
+                rename_facilities(config, f'host {host}', facility, facility_new)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+    #
+    # It makes no sense to configure udp/tcp transport per individual facility
+    #
+    if config.exists(base + ['host']):
+        for host in config.list_nodes(base + ['host']):
+            protocol = None
+            for facility in config.list_nodes(base + ['host', host, 'facility']):
+                tmp_path = base + ['host', host, 'facility', facility, 'protocol']
+                if config.exists(tmp_path):
+                    # We can only change the first one
+                    if protocol == None:
+                        protocol = config.return_value(tmp_path)
+                        config.set(base + ['host', host, 'protocol'], value=protocol)
+                    config.delete(tmp_path)
diff --git a/src/migration-scripts/system/26-to-27 b/src/migration-scripts/system/26-to-27
old mode 100755
new mode 100644
index 80bb82cbd..499e16e08
--- a/src/migration-scripts/system/26-to-27
+++ b/src/migration-scripts/system/26-to-27
@@ -1,47 +1,30 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
+
 # T5877: migrate 'system domain-search domain' to 'system domain-search'
 
-from sys import exit, argv
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['system', 'domain-search']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    exit(0)
 
-if config.exists(base + ['domain']):
-    entries = config.return_values(base + ['domain'])
-    config.delete(base + ['domain'])
-    for entry in entries:
-        config.set(base, value=entry, replace=False)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        return
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+    if config.exists(base + ['domain']):
+        entries = config.return_values(base + ['domain'])
+        config.delete(base + ['domain'])
+        for entry in entries:
+            config.set(base, value=entry, replace=False)
diff --git a/src/migration-scripts/system/6-to-7 b/src/migration-scripts/system/6-to-7
old mode 100755
new mode 100644
index d24521134..e91ccc4e9
--- a/src/migration-scripts/system/6-to-7
+++ b/src/migration-scripts/system/6-to-7
@@ -1,48 +1,36 @@
-#!/usr/bin/env python3
+# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Change smp_affinity to smp-affinity
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
-update_required = False
-
-intf_types = config.list_nodes(["interfaces"])
-
-for intf_type in intf_types:
-    intf_type_path = ["interfaces", intf_type]
-    intfs = config.list_nodes(intf_type_path)
-
-    for intf in intfs:
-        intf_path = intf_type_path + [intf]
-        if not config.exists(intf_path + ["smp_affinity"]):
-            # Nothing to do.
-            continue
-        else:
-            # Rename the node.
-            old_smp_affinity_path = intf_path + ["smp_affinity"]
-            config.rename(old_smp_affinity_path, "smp-affinity")
-            update_required = True
-
-if update_required:
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("failed to save the modified config: {}".format(e))
-        sys.exit(1)
-
-
-
+def migrate(config: ConfigTree) -> None:
+    intf_types = config.list_nodes(["interfaces"])
+
+    for intf_type in intf_types:
+        intf_type_path = ["interfaces", intf_type]
+        intfs = config.list_nodes(intf_type_path)
+
+        for intf in intfs:
+            intf_path = intf_type_path + [intf]
+            if not config.exists(intf_path + ["smp_affinity"]):
+                # Nothing to do.
+                continue
+            else:
+                # Rename the node.
+                old_smp_affinity_path = intf_path + ["smp_affinity"]
+                config.rename(old_smp_affinity_path, "smp-affinity")
+                update_required = True
diff --git a/src/migration-scripts/system/7-to-8 b/src/migration-scripts/system/7-to-8
old mode 100755
new mode 100644
index 5d084d2bf..64dd4dc93
--- a/src/migration-scripts/system/7-to-8
+++ b/src/migration-scripts/system/7-to-8
@@ -1,45 +1,39 @@
-#!/usr/bin/env python3
+# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Converts "system gateway-address" option to "protocols static route 0.0.0.0/0 next-hop $gw"
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(['system', 'gateway-address']):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-
-if not config.exists(['system', 'gateway-address']):
-    # Nothing to do
-    sys.exit(0)
-else:
     # Save the address
     gw = config.return_value(['system', 'gateway-address'])
 
     # Create the node for the new syntax
     # Note: next-hop is a tag node, gateway address is its child, not a value
     config.set(['protocols', 'static', 'route', '0.0.0.0/0', 'next-hop', gw])
 
     # Delete the node with the old syntax
     config.delete(['system', 'gateway-address'])
 
     # Now, the interesting part. Both route and next-hop are supposed to be tag nodes,
     # which you can verify with "cli-shell-api isTag $configPath".
     # They must be formatted as such to load correctly.
     config.set_tag(['protocols', 'static', 'route'])
     config.set_tag(['protocols', 'static', 'route', '0.0.0.0/0', 'next-hop'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/system/8-to-9 b/src/migration-scripts/system/8-to-9
old mode 100755
new mode 100644
index e3bb2bca8..ea5f7af81
--- a/src/migration-scripts/system/8-to-9
+++ b/src/migration-scripts/system/8-to-9
@@ -1,32 +1,26 @@
-#!/usr/bin/env python3
+# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Deletes "system package" option as it is deprecated
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(['system', 'package']):
+        # Nothing to do
+        return
 
-config = ConfigTree(config_file)
-
-if not config.exists(['system', 'package']):
-    # Nothing to do
-    sys.exit(0)
-else:
     # Delete the node with the old syntax
     config.delete(['system', 'package'])
-
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)
diff --git a/src/migration-scripts/vrf/0-to-1 b/src/migration-scripts/vrf/0-to-1
old mode 100755
new mode 100644
index 8187138d9..70abae2a8
--- a/src/migration-scripts/vrf/0-to-1
+++ b/src/migration-scripts/vrf/0-to-1
@@ -1,132 +1,113 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - T2450: drop interface-route and interface-route6 from "protocols vrf"
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['protocols', 'vrf']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-for vrf in config.list_nodes(base):
-    static_base = base + [vrf, 'static']
-    if not config.exists(static_base):
-        continue
-
-    #
-    # Migrate interface-route into route
-    #
-    interface_route_path = static_base + ['interface-route']
-    if config.exists(interface_route_path):
-        for route in config.list_nodes(interface_route_path):
-            interface = config.list_nodes(interface_route_path + [route, 'next-hop-interface'])
-
-            tmp = interface_route_path + [route, 'next-hop-interface']
-            for interface in config.list_nodes(tmp):
-                new_base = static_base + ['route', route, 'interface']
-                config.set(new_base)
-                config.set_tag(new_base)
-                config.copy(tmp + [interface], new_base + [interface])
-
-        config.delete(interface_route_path)
-
-    #
-    # Migrate interface-route6 into route6
-    #
-    interface_route_path = static_base + ['interface-route6']
-    if config.exists(interface_route_path):
-        for route in config.list_nodes(interface_route_path):
-            interface = config.list_nodes(interface_route_path + [route, 'next-hop-interface'])
-
-            tmp = interface_route_path + [route, 'next-hop-interface']
-            for interface in config.list_nodes(tmp):
-                new_base = static_base + ['route6', route, 'interface']
-                config.set(new_base)
-                config.set_tag(new_base)
-                config.copy(tmp + [interface], new_base + [interface])
-
-        config.delete(interface_route_path)
-
-    #
-    # Cleanup nodes inside route
-    #
-    route_path = static_base + ['route']
-    if config.exists(route_path):
-        for route in config.list_nodes(route_path):
-            next_hop = route_path + [route, 'next-hop']
-            if config.exists(next_hop):
-                for gateway in config.list_nodes(next_hop):
-                    interface_path = next_hop + [gateway, 'next-hop-interface']
-                    if config.exists(interface_path):
-                        config.rename(interface_path, 'interface')
-                    vrf_path = next_hop + [gateway, 'next-hop-vrf']
-                    if config.exists(vrf_path):
-                        config.rename(vrf_path, 'vrf')
-
-            next_hop = route_path + [route, 'interface']
-            if config.exists(next_hop):
-                for interface in config.list_nodes(next_hop):
-                    interface_path = next_hop + [interface, 'next-hop-interface']
-                    if config.exists(interface_path):
-                        config.rename(interface_path, 'interface')
-                    vrf_path = next_hop + [interface, 'next-hop-vrf']
-                    if config.exists(vrf_path):
-                        config.rename(vrf_path, 'vrf')
-
-    #
-    # Cleanup nodes inside route6
-    #
-    route_path = static_base + ['route6']
-    if config.exists(route_path):
-        for route in config.list_nodes(route_path):
-            next_hop = route_path + [route, 'next-hop']
-            if config.exists(next_hop):
-                for gateway in config.list_nodes(next_hop):
-                    vrf_path = next_hop + [gateway, 'next-hop-vrf']
-                    if config.exists(vrf_path):
-                        config.rename(vrf_path, 'vrf')
-
-            next_hop = route_path + [route, 'interface']
-            if config.exists(next_hop):
-                for interface in config.list_nodes(next_hop):
-                    interface_path = next_hop + [interface, 'next-hop-interface']
-                    if config.exists(interface_path):
-                        config.rename(interface_path, 'interface')
-                    vrf_path = next_hop + [interface, 'next-hop-vrf']
-                    if config.exists(vrf_path):
-                        config.rename(vrf_path, 'vrf')
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    for vrf in config.list_nodes(base):
+        static_base = base + [vrf, 'static']
+        if not config.exists(static_base):
+            continue
+
+        #
+        # Migrate interface-route into route
+        #
+        interface_route_path = static_base + ['interface-route']
+        if config.exists(interface_route_path):
+            for route in config.list_nodes(interface_route_path):
+                interface = config.list_nodes(interface_route_path + [route, 'next-hop-interface'])
+
+                tmp = interface_route_path + [route, 'next-hop-interface']
+                for interface in config.list_nodes(tmp):
+                    new_base = static_base + ['route', route, 'interface']
+                    config.set(new_base)
+                    config.set_tag(new_base)
+                    config.copy(tmp + [interface], new_base + [interface])
+
+            config.delete(interface_route_path)
+
+        #
+        # Migrate interface-route6 into route6
+        #
+        interface_route_path = static_base + ['interface-route6']
+        if config.exists(interface_route_path):
+            for route in config.list_nodes(interface_route_path):
+                interface = config.list_nodes(interface_route_path + [route, 'next-hop-interface'])
+
+                tmp = interface_route_path + [route, 'next-hop-interface']
+                for interface in config.list_nodes(tmp):
+                    new_base = static_base + ['route6', route, 'interface']
+                    config.set(new_base)
+                    config.set_tag(new_base)
+                    config.copy(tmp + [interface], new_base + [interface])
+
+            config.delete(interface_route_path)
+
+        #
+        # Cleanup nodes inside route
+        #
+        route_path = static_base + ['route']
+        if config.exists(route_path):
+            for route in config.list_nodes(route_path):
+                next_hop = route_path + [route, 'next-hop']
+                if config.exists(next_hop):
+                    for gateway in config.list_nodes(next_hop):
+                        interface_path = next_hop + [gateway, 'next-hop-interface']
+                        if config.exists(interface_path):
+                            config.rename(interface_path, 'interface')
+                        vrf_path = next_hop + [gateway, 'next-hop-vrf']
+                        if config.exists(vrf_path):
+                            config.rename(vrf_path, 'vrf')
+
+                next_hop = route_path + [route, 'interface']
+                if config.exists(next_hop):
+                    for interface in config.list_nodes(next_hop):
+                        interface_path = next_hop + [interface, 'next-hop-interface']
+                        if config.exists(interface_path):
+                            config.rename(interface_path, 'interface')
+                        vrf_path = next_hop + [interface, 'next-hop-vrf']
+                        if config.exists(vrf_path):
+                            config.rename(vrf_path, 'vrf')
+
+        #
+        # Cleanup nodes inside route6
+        #
+        route_path = static_base + ['route6']
+        if config.exists(route_path):
+            for route in config.list_nodes(route_path):
+                next_hop = route_path + [route, 'next-hop']
+                if config.exists(next_hop):
+                    for gateway in config.list_nodes(next_hop):
+                        vrf_path = next_hop + [gateway, 'next-hop-vrf']
+                        if config.exists(vrf_path):
+                            config.rename(vrf_path, 'vrf')
+
+                next_hop = route_path + [route, 'interface']
+                if config.exists(next_hop):
+                    for interface in config.list_nodes(next_hop):
+                        interface_path = next_hop + [interface, 'next-hop-interface']
+                        if config.exists(interface_path):
+                            config.rename(interface_path, 'interface')
+                        vrf_path = next_hop + [interface, 'next-hop-vrf']
+                        if config.exists(vrf_path):
+                            config.rename(vrf_path, 'vrf')
diff --git a/src/migration-scripts/vrf/1-to-2 b/src/migration-scripts/vrf/1-to-2
old mode 100755
new mode 100644
index 52d4c2c7b..557a9ec58
--- a/src/migration-scripts/vrf/1-to-2
+++ b/src/migration-scripts/vrf/1-to-2
@@ -1,62 +1,43 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # - T3344: migrate routing options from "protocols vrf" to "vrf <name> protocols"
 
-from sys import argv
-from sys import exit
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
+base = ['protocols', 'vrf']
 
-file_name = argv[1]
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
-with open(file_name, 'r') as f:
-    config_file = f.read()
+    vrf_base = ['vrf', 'name']
+    config.set(vrf_base)
+    config.set_tag(vrf_base)
 
-base = ['protocols', 'vrf']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-vrf_base = ['vrf', 'name']
-config.set(vrf_base)
-config.set_tag(vrf_base)
-
-# Copy all existing static routes to the new base node under "vrf name <name> protocols static"
-for vrf in config.list_nodes(base):
-    static_base = base + [vrf, 'static']
-    if not config.exists(static_base):
-        continue
-
-    new_static_base = vrf_base + [vrf, 'protocols']
-    config.set(new_static_base)
-    config.copy(static_base, new_static_base + ['static'])
-    config.set_tag(new_static_base + ['static', 'route'])
-
-# Now delete the old configuration
-config.delete(base)
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+    # Copy all existing static routes to the new base node under "vrf name <name> protocols static"
+    for vrf in config.list_nodes(base):
+        static_base = base + [vrf, 'static']
+        if not config.exists(static_base):
+            continue
+
+        new_static_base = vrf_base + [vrf, 'protocols']
+        config.set(new_static_base)
+        config.copy(static_base, new_static_base + ['static'])
+        config.set_tag(new_static_base + ['static', 'route'])
+
+    # Now delete the old configuration
+    config.delete(base)
diff --git a/src/migration-scripts/vrf/2-to-3 b/src/migration-scripts/vrf/2-to-3
old mode 100755
new mode 100644
index d45b185ee..acacffb41
--- a/src/migration-scripts/vrf/2-to-3
+++ b/src/migration-scripts/vrf/2-to-3
@@ -1,144 +1,125 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # Since connection tracking zones are int16, VRFs tables maximum value must
 # be limited to 65535
 # Also, interface names in nftables cannot start from numbers,
 # so VRF name should not start from a number
 
-from sys import argv
-from sys import exit
 from random import randrange
 from random import choice
 from string import ascii_lowercase
 from vyos.configtree import ConfigTree
 import re
 
 
 # Helper function to find all config items with a VRF name
 def _search_vrfs(config_commands, vrf_name):
     vrf_values = []
     # Regex to find path of config command with old VRF
     regex_filter = re.compile(rf'^set (?P<cmd_path>[^\']+vrf) \'{vrf_name}\'$')
     # Check each command for VRF value
     for config_command in config_commands:
         search_result = regex_filter.search(config_command)
         if search_result:
             # Append VRF command to a list
             vrf_values.append(search_result.group('cmd_path').split())
     if vrf_values:
         return vrf_values
     else:
         return None
 
 
 # Helper function to find all config items with a table number
 def _search_tables(config_commands, table_num):
     table_items = {'table_tags': [], 'table_values': []}
     # Regex to find values and nodes with a table number
     regex_tags = re.compile(rf'^set (?P<cmd_path>[^\']+table {table_num}) ?.*$')
     regex_values = re.compile(
         rf'^set (?P<cmd_path>[^\']+table) \'{table_num}\'$')
     for config_command in config_commands:
         # Search for tag nodes
         search_result = regex_tags.search(config_command)
         if search_result:
             # Append table node path to a tag nodes list
             cmd_path = search_result.group('cmd_path').split()
             if cmd_path not in table_items['table_tags']:
                 table_items['table_tags'].append(cmd_path)
         # Search for value nodes
         search_result = regex_values.search(config_command)
         if search_result:
             # Append table node path to a value nodes list
             table_items['table_values'].append(
                 search_result.group('cmd_path').split())
     return table_items
 
 
-if len(argv) < 2:
-    print("Must specify file name!")
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['vrf', 'name']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-# Get a list of all currently used VRFs and tables
-vrfs_current = {}
-for vrf in config.list_nodes(base):
-    vrfs_current[vrf] = int(config.return_value(base + [vrf, 'table']))
-
-# Check VRF names and table numbers
-name_regex = re.compile(r'^\d.*$')
-for vrf_name, vrf_table in vrfs_current.items():
-    # Check table number
-    if vrf_table > 65535:
-        # Find new unused table number
-        vrfs_current[vrf_name] = None
-        while not vrfs_current[vrf_name]:
-            table_random = randrange(100, 65535)
-            if table_random not in vrfs_current.values():
-                vrfs_current[vrf_name] = table_random
-        # Update number to a new one
-        config.set(['vrf', 'name', vrf_name, 'table'],
-                   vrfs_current[vrf_name],
-                   replace=True)
-        # Check config items with old table number and replace to new one
-        config_commands = config.to_commands().split('\n')
-        table_config_lines = _search_tables(config_commands, vrf_table)
-        # Rename table nodes
-        if table_config_lines.get('table_tags'):
-            for table_config_path in table_config_lines.get('table_tags'):
-                config.rename(table_config_path, f'{vrfs_current[vrf_name]}')
-        # Replace table values
-        if table_config_lines.get('table_values'):
-            for table_config_path in table_config_lines.get('table_values'):
-                config.set(table_config_path,
-                           f'{vrfs_current[vrf_name]}',
-                           replace=True)
-
-    # Check VRF name
-    if name_regex.match(vrf_name):
-        vrf_name_new = None
-        while not vrf_name_new:
-            vrf_name_rand = f'{choice(ascii_lowercase)}{vrf_name}'[:15]
-            if vrf_name_rand not in vrfs_current:
-                vrf_name_new = vrf_name_rand
-        # Update VRF name to a new one
-        config.rename(['vrf', 'name', vrf_name], vrf_name_new)
-        # Check config items with old VRF name and replace to new one
-        config_commands = config.to_commands().split('\n')
-        vrf_config_lines = _search_vrfs(config_commands, vrf_name)
-        # Rename VRF to a new name
-        if vrf_config_lines:
-            for vrf_value_path in vrf_config_lines:
-                config.set(vrf_value_path, vrf_name_new, replace=True)
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    exit(1)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    # Get a list of all currently used VRFs and tables
+    vrfs_current = {}
+    for vrf in config.list_nodes(base):
+        vrfs_current[vrf] = int(config.return_value(base + [vrf, 'table']))
+
+    # Check VRF names and table numbers
+    name_regex = re.compile(r'^\d.*$')
+    for vrf_name, vrf_table in vrfs_current.items():
+        # Check table number
+        if vrf_table > 65535:
+            # Find new unused table number
+            vrfs_current[vrf_name] = None
+            while not vrfs_current[vrf_name]:
+                table_random = randrange(100, 65535)
+                if table_random not in vrfs_current.values():
+                    vrfs_current[vrf_name] = table_random
+            # Update number to a new one
+            config.set(['vrf', 'name', vrf_name, 'table'],
+                       vrfs_current[vrf_name],
+                       replace=True)
+            # Check config items with old table number and replace to new one
+            config_commands = config.to_commands().split('\n')
+            table_config_lines = _search_tables(config_commands, vrf_table)
+            # Rename table nodes
+            if table_config_lines.get('table_tags'):
+                for table_config_path in table_config_lines.get('table_tags'):
+                    config.rename(table_config_path, f'{vrfs_current[vrf_name]}')
+            # Replace table values
+            if table_config_lines.get('table_values'):
+                for table_config_path in table_config_lines.get('table_values'):
+                    config.set(table_config_path,
+                               f'{vrfs_current[vrf_name]}',
+                               replace=True)
+
+        # Check VRF name
+        if name_regex.match(vrf_name):
+            vrf_name_new = None
+            while not vrf_name_new:
+                vrf_name_rand = f'{choice(ascii_lowercase)}{vrf_name}'[:15]
+                if vrf_name_rand not in vrfs_current:
+                    vrf_name_new = vrf_name_rand
+            # Update VRF name to a new one
+            config.rename(['vrf', 'name', vrf_name], vrf_name_new)
+            # Check config items with old VRF name and replace to new one
+            config_commands = config.to_commands().split('\n')
+            vrf_config_lines = _search_vrfs(config_commands, vrf_name)
+            # Rename VRF to a new name
+            if vrf_config_lines:
+                for vrf_value_path in vrf_config_lines:
+                    config.set(vrf_value_path, vrf_name_new, replace=True)
diff --git a/src/migration-scripts/vrrp/1-to-2 b/src/migration-scripts/vrrp/1-to-2
old mode 100755
new mode 100644
index dba5af81c..8639a7553
--- a/src/migration-scripts/vrrp/1-to-2
+++ b/src/migration-scripts/vrrp/1-to-2
@@ -1,270 +1,250 @@
-#!/usr/bin/env python3
+# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2018 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 import re
-import sys
 
 from vyos.configtree import ConfigTree
 
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
-config = ConfigTree(config_file)
-
 # Convert the old VRRP syntax to the new syntax
 
 # The old approach was to put VRRP groups inside interfaces,
 # as in "interfaces ethernet eth0 vrrp vrrp-group 10 ...".
 # It was supported only under ethernet and bonding and their
 # respective vif, vif-s, and vif-c subinterfaces
 
 def get_vrrp_group(path):
     group = {"preempt": True, "rfc_compatibility": False, "disable": False}
 
     if config.exists(path + ["advertise-interval"]):
         group["advertise_interval"] = config.return_value(path + ["advertise-interval"])
 
     if config.exists(path + ["description"]):
         group["description"] = config.return_value(path + ["description"])
 
     if config.exists(path + ["disable"]):
         group["disable"] = True
 
     if config.exists(path + ["hello-source-address"]):
         group["hello_source"] = config.return_value(path + ["hello-source-address"])
 
     # 1.1.8 didn't have it, but earlier 1.2.0 did, we don't want to break
     # configs of early adopters!
     if config.exists(path + ["peer-address"]):
         group["peer_address"] = config.return_value(path + ["peer-address"])
 
     if config.exists(path + ["preempt"]):
         preempt = config.return_value(path + ["preempt"])
         if preempt == "false":
             group["preempt"] = False
 
     if config.exists(path + ["rfc3768-compatibility"]):
         group["rfc_compatibility"] = True
 
     if config.exists(path + ["preempt-delay"]):
         group["preempt_delay"] = config.return_value(path + ["preempt-delay"])
 
     if config.exists(path + ["priority"]):
         group["priority"] = config.return_value(path + ["priority"])
 
     if config.exists(path + ["sync-group"]):
         group["sync_group"] = config.return_value(path + ["sync-group"])
 
     if config.exists(path + ["authentication", "type"]):
         group["auth_type"] = config.return_value(path + ["authentication", "type"])
 
     if config.exists(path + ["authentication", "password"]):
         group["auth_password"] = config.return_value(path + ["authentication", "password"])
 
     if config.exists(path + ["virtual-address"]):
         group["virtual_addresses"] = config.return_values(path + ["virtual-address"])
 
     if config.exists(path + ["run-transition-scripts"]):
         if config.exists(path + ["run-transition-scripts", "master"]):
             group["master_script"] = config.return_value(path + ["run-transition-scripts", "master"])
         if config.exists(path + ["run-transition-scripts", "backup"]):
             group["backup_script"] = config.return_value(path + ["run-transition-scripts", "backup"])
         if config.exists(path + ["run-transition-scripts", "fault"]):
             group["fault_script"] = config.return_value(path + ["run-transition-scripts", "fault"])
 
     # Also not present in 1.1.8, but supported by earlier 1.2.0
     if config.exists(path + ["health-check"]):
         if config.exists(path + ["health-check", "interval"]):
             group["health_check_interval"] = config.return_value(path + ["health-check", "interval"])
         if config.exists(path + ["health-check", "failure-count"]):
             group["health_check_count"] = config.return_value(path + ["health-check", "failure-count"])
         if config.exists(path + ["health-check", "script"]):
             group["health_check_script"] = config.return_value(path + ["health-check", "script"])
 
     return group
 
 # Since VRRP is all over the place, there's no way to just check a path and exit early
 # if it doesn't exist, we have to walk all interfaces and collect VRRP settings from them.
 # Only if no data is collected from any interface we can conclude that VRRP is not configured
 # and exit.
 
-groups = []
-base_paths = []
-
-if config.exists(["interfaces", "ethernet"]):
-    base_paths.append("ethernet")
-if config.exists(["interfaces", "bonding"]):
-    base_paths.append("bonding")
-
-for bp in base_paths:
-    parent_path = ["interfaces", bp]
-
-    parent_intfs = config.list_nodes(parent_path)
-
-    for pi in parent_intfs:
-        # Extract VRRP groups from the parent interface
-        vg_path =[pi, "vrrp", "vrrp-group"]
-        if config.exists(parent_path + vg_path):
-            pgroups = config.list_nodes(parent_path + vg_path)
-            for pg in pgroups:
-                g = get_vrrp_group(parent_path + vg_path + [pg])
-                g["interface"] = pi
-                g["vrid"] = pg
-                groups.append(g)
-
-            # Delete the VRRP subtree
-            # If left in place, configs will not load correctly
-            config.delete(parent_path + [pi, "vrrp"])
-
-        # Extract VRRP groups from 802.1q VLAN interfaces
-        if config.exists(parent_path + [pi, "vif"]):
-            vifs = config.list_nodes(parent_path + [pi, "vif"])
-            for vif in vifs:
-                vif_vg_path = [pi, "vif", vif, "vrrp", "vrrp-group"]
-                if config.exists(parent_path + vif_vg_path):
-                    vifgroups = config.list_nodes(parent_path + vif_vg_path)
-                    for vif_group in vifgroups:
-                        g = get_vrrp_group(parent_path + vif_vg_path + [vif_group])
-                        g["interface"] = "{0}.{1}".format(pi, vif)
-                        g["vrid"] = vif_group
-                        groups.append(g)
-
-                    config.delete(parent_path + [pi, "vif", vif, "vrrp"])
-
-        # Extract VRRP groups from 802.3ad QinQ service VLAN interfaces
-        if config.exists(parent_path + [pi, "vif-s"]):
-            vif_ss = config.list_nodes(parent_path + [pi, "vif-s"])
-            for vif_s in vif_ss:
-                vifs_vg_path = [pi, "vif-s", vif_s, "vrrp", "vrrp-group"]
-                if config.exists(parent_path + vifs_vg_path):
-                    vifsgroups = config.list_nodes(parent_path + vifs_vg_path)
-                    for vifs_group in vifsgroups:
-                        g = get_vrrp_group(parent_path + vifs_vg_path + [vifs_group])
-                        g["interface"] = "{0}.{1}".format(pi, vif_s)
-                        g["vrid"] = vifs_group
-                        groups.append(g)
-
-                    config.delete(parent_path + [pi, "vif-s", vif_s, "vrrp"])
-
-                # Extract VRRP groups from QinQ client VLAN interfaces nested in the vif-s
-                if config.exists(parent_path + [pi, "vif-s", vif_s, "vif-c"]):
-                    vif_cs = config.list_nodes(parent_path + [pi, "vif-s", vif_s, "vif-c"])
-                    for vif_c in vif_cs:
-                         vifc_vg_path = [pi, "vif-s", vif_s, "vif-c", vif_c, "vrrp", "vrrp-group"]
-                         vifcgroups = config.list_nodes(parent_path + vifc_vg_path)
-                         for vifc_group in vifcgroups:
-                              g = get_vrrp_group(parent_path + vifc_vg_path + [vifc_group])
-                              g["interface"] = "{0}.{1}.{2}".format(pi, vif_s, vif_c)
-                              g["vrid"] = vifc_group
-                              groups.append(g)
-
-                         config.delete(parent_path + [pi, "vif-s", vif_s, "vif-c", vif_c, "vrrp"])
-
-# If nothing was collected before this point, it means the config has no VRRP setup
-if not groups:
-    sys.exit(0)
-
-# Otherwise, there is VRRP to convert
- 
-# Now convert the collected groups to the new syntax
-base_group_path = ["high-availability", "vrrp", "group"]
-sync_path = ["high-availability", "vrrp", "sync-group"]
-
-for g in groups:
-    group_name = "{0}-{1}".format(g["interface"], g["vrid"])
-    group_path = base_group_path + [group_name]
-
-    config.set(group_path + ["interface"], value=g["interface"])
-    config.set(group_path + ["vrid"], value=g["vrid"])
-
-    if "advertise_interval" in g:
-        config.set(group_path + ["advertise-interval"], value=g["advertise_interval"])
-
-    if "priority" in g:
-        config.set(group_path + ["priority"], value=g["priority"])
-
-    if not g["preempt"]:
-        config.set(group_path + ["no-preempt"], value=None)
-
-    if "preempt_delay" in g:
-        config.set(group_path + ["preempt-delay"], value=g["preempt_delay"])
-
-    if g["rfc_compatibility"]:
-        config.set(group_path + ["rfc3768-compatibility"], value=None)
-
-    if g["disable"]:
-        config.set(group_path + ["disable"], value=None)
-
-    if "hello_source" in g:
-        config.set(group_path + ["hello-source-address"], value=g["hello_source"])
-
-    if "peer_address" in g:
-        config.set(group_path + ["peer-address"], value=g["peer_address"])
-
-    if "auth_password" in g:
-        config.set(group_path + ["authentication", "password"], value=g["auth_password"])
-    if "auth_type" in g:
-        config.set(group_path + ["authentication", "type"], value=g["auth_type"])
-
-    if "master_script" in g:
-        config.set(group_path + ["transition-script", "master"], value=g["master_script"])
-    if "backup_script" in g:
-        config.set(group_path + ["transition-script", "backup"], value=g["backup_script"])
-    if "fault_script" in g:
-        config.set(group_path + ["transition-script", "fault"], value=g["fault_script"])
-
-    if "health_check_interval" in g:
-        config.set(group_path + ["health-check", "interval"], value=g["health_check_interval"])
-    if "health_check_count" in g:
-        config.set(group_path + ["health-check", "failure-count"], value=g["health_check_count"])
-    if "health_check_script" in g:
-        config.set(group_path + ["health-check", "script"], value=g["health_check_script"])
-
-    # Not that it should ever be absent...
-    if "virtual_addresses" in g:
-        # The new CLI disallows addresses without prefix length
-        # Pre-rewrite configs didn't support IPv6 VRRP, but handle it anyway
-        for va in g["virtual_addresses"]:
-            if not re.search(r'/', va):
-                if re.search(r':', va):
-                    va = "{0}/128".format(va)
-                else:
-                    va = "{0}/32".format(va)
-            config.set(group_path + ["virtual-address"], value=va, replace=False)
-
-    # Sync group
-    if "sync_group" in g:
-        config.set(sync_path + [g["sync_group"], "member"], value=group_name, replace=False)
-
-# Set the tag flag
-config.set_tag(base_group_path)
-if config.exists(sync_path):
-    config.set_tag(sync_path)
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print("Failed to save the modified config: {}".format(e))
-    sys.exit(1)
+def migrate(config: ConfigTree) -> None:
+    groups = []
+    base_paths = []
+
+    if config.exists(["interfaces", "ethernet"]):
+        base_paths.append("ethernet")
+    if config.exists(["interfaces", "bonding"]):
+        base_paths.append("bonding")
+
+    for bp in base_paths:
+        parent_path = ["interfaces", bp]
+
+        parent_intfs = config.list_nodes(parent_path)
+
+        for pi in parent_intfs:
+            # Extract VRRP groups from the parent interface
+            vg_path =[pi, "vrrp", "vrrp-group"]
+            if config.exists(parent_path + vg_path):
+                pgroups = config.list_nodes(parent_path + vg_path)
+                for pg in pgroups:
+                    g = get_vrrp_group(parent_path + vg_path + [pg])
+                    g["interface"] = pi
+                    g["vrid"] = pg
+                    groups.append(g)
+
+                # Delete the VRRP subtree
+                # If left in place, configs will not load correctly
+                config.delete(parent_path + [pi, "vrrp"])
+
+            # Extract VRRP groups from 802.1q VLAN interfaces
+            if config.exists(parent_path + [pi, "vif"]):
+                vifs = config.list_nodes(parent_path + [pi, "vif"])
+                for vif in vifs:
+                    vif_vg_path = [pi, "vif", vif, "vrrp", "vrrp-group"]
+                    if config.exists(parent_path + vif_vg_path):
+                        vifgroups = config.list_nodes(parent_path + vif_vg_path)
+                        for vif_group in vifgroups:
+                            g = get_vrrp_group(parent_path + vif_vg_path + [vif_group])
+                            g["interface"] = "{0}.{1}".format(pi, vif)
+                            g["vrid"] = vif_group
+                            groups.append(g)
+
+                        config.delete(parent_path + [pi, "vif", vif, "vrrp"])
+
+            # Extract VRRP groups from 802.3ad QinQ service VLAN interfaces
+            if config.exists(parent_path + [pi, "vif-s"]):
+                vif_ss = config.list_nodes(parent_path + [pi, "vif-s"])
+                for vif_s in vif_ss:
+                    vifs_vg_path = [pi, "vif-s", vif_s, "vrrp", "vrrp-group"]
+                    if config.exists(parent_path + vifs_vg_path):
+                        vifsgroups = config.list_nodes(parent_path + vifs_vg_path)
+                        for vifs_group in vifsgroups:
+                            g = get_vrrp_group(parent_path + vifs_vg_path + [vifs_group])
+                            g["interface"] = "{0}.{1}".format(pi, vif_s)
+                            g["vrid"] = vifs_group
+                            groups.append(g)
+
+                        config.delete(parent_path + [pi, "vif-s", vif_s, "vrrp"])
+
+                    # Extract VRRP groups from QinQ client VLAN interfaces nested in the vif-s
+                    if config.exists(parent_path + [pi, "vif-s", vif_s, "vif-c"]):
+                        vif_cs = config.list_nodes(parent_path + [pi, "vif-s", vif_s, "vif-c"])
+                        for vif_c in vif_cs:
+                             vifc_vg_path = [pi, "vif-s", vif_s, "vif-c", vif_c, "vrrp", "vrrp-group"]
+                             vifcgroups = config.list_nodes(parent_path + vifc_vg_path)
+                             for vifc_group in vifcgroups:
+                                  g = get_vrrp_group(parent_path + vifc_vg_path + [vifc_group])
+                                  g["interface"] = "{0}.{1}.{2}".format(pi, vif_s, vif_c)
+                                  g["vrid"] = vifc_group
+                                  groups.append(g)
+
+                             config.delete(parent_path + [pi, "vif-s", vif_s, "vif-c", vif_c, "vrrp"])
+
+    # If nothing was collected before this point, it means the config has no VRRP setup
+    if not groups:
+        return
+
+    # Otherwise, there is VRRP to convert
+     
+    # Now convert the collected groups to the new syntax
+    base_group_path = ["high-availability", "vrrp", "group"]
+    sync_path = ["high-availability", "vrrp", "sync-group"]
+
+    for g in groups:
+        group_name = "{0}-{1}".format(g["interface"], g["vrid"])
+        group_path = base_group_path + [group_name]
+
+        config.set(group_path + ["interface"], value=g["interface"])
+        config.set(group_path + ["vrid"], value=g["vrid"])
+
+        if "advertise_interval" in g:
+            config.set(group_path + ["advertise-interval"], value=g["advertise_interval"])
+
+        if "priority" in g:
+            config.set(group_path + ["priority"], value=g["priority"])
+
+        if not g["preempt"]:
+            config.set(group_path + ["no-preempt"], value=None)
+
+        if "preempt_delay" in g:
+            config.set(group_path + ["preempt-delay"], value=g["preempt_delay"])
+
+        if g["rfc_compatibility"]:
+            config.set(group_path + ["rfc3768-compatibility"], value=None)
+
+        if g["disable"]:
+            config.set(group_path + ["disable"], value=None)
+
+        if "hello_source" in g:
+            config.set(group_path + ["hello-source-address"], value=g["hello_source"])
+
+        if "peer_address" in g:
+            config.set(group_path + ["peer-address"], value=g["peer_address"])
+
+        if "auth_password" in g:
+            config.set(group_path + ["authentication", "password"], value=g["auth_password"])
+        if "auth_type" in g:
+            config.set(group_path + ["authentication", "type"], value=g["auth_type"])
+
+        if "master_script" in g:
+            config.set(group_path + ["transition-script", "master"], value=g["master_script"])
+        if "backup_script" in g:
+            config.set(group_path + ["transition-script", "backup"], value=g["backup_script"])
+        if "fault_script" in g:
+            config.set(group_path + ["transition-script", "fault"], value=g["fault_script"])
+
+        if "health_check_interval" in g:
+            config.set(group_path + ["health-check", "interval"], value=g["health_check_interval"])
+        if "health_check_count" in g:
+            config.set(group_path + ["health-check", "failure-count"], value=g["health_check_count"])
+        if "health_check_script" in g:
+            config.set(group_path + ["health-check", "script"], value=g["health_check_script"])
+
+        # Not that it should ever be absent...
+        if "virtual_addresses" in g:
+            # The new CLI disallows addresses without prefix length
+            # Pre-rewrite configs didn't support IPv6 VRRP, but handle it anyway
+            for va in g["virtual_addresses"]:
+                if not re.search(r'/', va):
+                    if re.search(r':', va):
+                        va = "{0}/128".format(va)
+                    else:
+                        va = "{0}/32".format(va)
+                config.set(group_path + ["virtual-address"], value=va, replace=False)
+
+        # Sync group
+        if "sync_group" in g:
+            config.set(sync_path + [g["sync_group"], "member"], value=group_name, replace=False)
+
+    # Set the tag flag
+    config.set_tag(base_group_path)
+    if config.exists(sync_path):
+        config.set_tag(sync_path)
diff --git a/src/migration-scripts/vrrp/2-to-3 b/src/migration-scripts/vrrp/2-to-3
old mode 100755
new mode 100644
index ed583b489..468918f91
--- a/src/migration-scripts/vrrp/2-to-3
+++ b/src/migration-scripts/vrrp/2-to-3
@@ -1,62 +1,44 @@
-#!/usr/bin/env python3
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2021 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # T3847: vrrp config cleanup
 
-from sys import argv
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print('Must specify file name!')
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['high-availability', 'vrrp']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-if config.exists(base + ['group']):
-    for group in config.list_nodes(base + ['group']):
-        group_base = base + ['group', group]
-
-        # Deprecated option
-        tmp = group_base + ['transition-script', 'mode-force']
-        if config.exists(tmp):
-            config.delete(tmp)
-
-        # Rename virtual-address -> address
-        tmp = group_base + ['virtual-address']
-        if config.exists(tmp):
-            config.rename(tmp, 'address')
-
-        # Rename virtual-address-excluded -> excluded-address
-        tmp = group_base + ['virtual-address-excluded']
-        if config.exists(tmp):
-            config.rename(tmp, 'excluded-address')
-
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
+
+    if config.exists(base + ['group']):
+        for group in config.list_nodes(base + ['group']):
+            group_base = base + ['group', group]
+
+            # Deprecated option
+            tmp = group_base + ['transition-script', 'mode-force']
+            if config.exists(tmp):
+                config.delete(tmp)
+
+            # Rename virtual-address -> address
+            tmp = group_base + ['virtual-address']
+            if config.exists(tmp):
+                config.rename(tmp, 'address')
+
+            # Rename virtual-address-excluded -> excluded-address
+            tmp = group_base + ['virtual-address-excluded']
+            if config.exists(tmp):
+                config.rename(tmp, 'excluded-address')
diff --git a/src/migration-scripts/vrrp/3-to-4 b/src/migration-scripts/vrrp/3-to-4
old mode 100755
new mode 100644
index e5d93578c..9f05cf7a1
--- a/src/migration-scripts/vrrp/3-to-4
+++ b/src/migration-scripts/vrrp/3-to-4
@@ -1,51 +1,32 @@
-#!/usr/bin/env python3
+# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
 #
-# Copyright (C) 2023 VyOS maintainers and contributors
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
 #
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
+# This library 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 for more details.
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
-from sys import argv
 from vyos.configtree import ConfigTree
 
-if len(argv) < 2:
-    print('Must specify file name!')
-    exit(1)
-
-file_name = argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
-
 base = ['high-availability', 'virtual-server']
-config = ConfigTree(config_file)
-
-if not config.exists(base):
-    # Nothing to do
-    exit(0)
-
-if config.exists(base):
-    for vs in config.list_nodes(base):
-        vs_base = base + [vs]
 
-        # If the fwmark is used, the address is not required
-        if not config.exists(vs_base + ['fwmark']):
-            # add option: 'virtual-server <tag> address x.x.x.x'
-            config.set(vs_base + ['address'], value=vs)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(base):
+        # Nothing to do
+        return
 
+    if config.exists(base):
+        for vs in config.list_nodes(base):
+            vs_base = base + [vs]
 
-try:
-    with open(file_name, 'w') as f:
-        f.write(config.to_string())
-except OSError as e:
-    print(f'Failed to save the modified config: {e}')
-    exit(1)
+            # If the fwmark is used, the address is not required
+            if not config.exists(vs_base + ['fwmark']):
+                # add option: 'virtual-server <tag> address x.x.x.x'
+                config.set(vs_base + ['address'], value=vs)
diff --git a/src/migration-scripts/webproxy/1-to-2 b/src/migration-scripts/webproxy/1-to-2
old mode 100755
new mode 100644
index 03f357878..5a4847474
--- a/src/migration-scripts/webproxy/1-to-2
+++ b/src/migration-scripts/webproxy/1-to-2
@@ -1,39 +1,33 @@
-#!/usr/bin/env python3
+# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 # migrate old style `webproxy proxy-bypass 1.2.3.4/24`
 # to new style `webproxy whitelist destination-address 1.2.3.4/24`
 
-import sys
-
 from vyos.configtree import ConfigTree
 
-if len(sys.argv) < 2:
-    print("Must specify file name!")
-    sys.exit(1)
-
-file_name = sys.argv[1]
-
-with open(file_name, 'r') as f:
-    config_file = f.read()
+cfg_webproxy_base = ['service', 'webproxy']
 
-config = ConfigTree(config_file)
+def migrate(config: ConfigTree) -> None:
+    if not config.exists(cfg_webproxy_base + ['proxy-bypass']):
+        # Nothing to do
+        return
 
-cfg_webproxy_base = ['service', 'webproxy']
-if not config.exists(cfg_webproxy_base + ['proxy-bypass']):
-    # Nothing to do
-    sys.exit(0)
-else:
     bypass_addresses = config.return_values(cfg_webproxy_base + ['proxy-bypass'])
     # delete old configuration node
     config.delete(cfg_webproxy_base + ['proxy-bypass'])
     for bypass_address in bypass_addresses:
         # add data to new configuration node
         config.set(cfg_webproxy_base + ['whitelist', 'destination-address'], value=bypass_address, replace=False)
-
-    # save updated configuration
-    try:
-        with open(file_name, 'w') as f:
-            f.write(config.to_string())
-    except OSError as e:
-        print("Failed to save the modified config: {}".format(e))
-        sys.exit(1)