summaryrefslogtreecommitdiff
path: root/src/migration-scripts/cluster/1-to-2
blob: a2e589155e4960b173ba99ec0d4673bd47aff2d4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
#!/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/>.
#

import re
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)

    if not config.exists(['cluster']):
        # Cluster is not set -- nothing to do at all
        sys.exit(0)

    # 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)

            # 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)

            # 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)

            # 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)

            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)