summaryrefslogtreecommitdiff
path: root/src/migration-scripts/config-management/1-to-2
blob: cd6392d400726ebbd1f7ec63b2cfc6de59d997eb (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
# Copyright (C) VyOS Inc.
#
# 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/>.
#
# T6304: rework 'system config-management commit-archive' from a flat list of
# URLs ('location <url>') into a structured, per-destination tagNode carrying
# an explicit transport protocol, server, path, port and authentication.

import re

from urllib.parse import unquote
from urllib.parse import urlsplit

from vyos.configtree import ConfigTree

base = ['system', 'config-management', 'commit-archive']

# URL schemes that map 1:1 onto a transport protocol node of the same name;
# git schemes are handled separately below.
direct_schemes = {'scp', 'sftp', 'ssh', 'ftp', 'ftps', 'http', 'https', 'tftp'}
# transports the new 'git transport' leaf accepts ('file' is a local repo)
git_transports = {'https', 'http', 'ssh', 'file'}


def _decode_path(path: str) -> str:
    """Percent-decode a URL path, keeping an escaped slash (%2F) intact so it
    is not turned into a path separator (the URL builder re-encodes it).
    """
    return ''.join(
        part if part.lower() == '%2f' else unquote(part)
        for part in re.split('(%2[fF])', path)
    )


def scheme_to_target(scheme: str):
    """Map a URL scheme to a (protocol node, git transport) tuple, or None if
    unsupported. A git scheme maps to the 'git' node carrying its transport
    ('git' alone carries none); every other scheme maps to a node of its name.
    """
    protocol, _, transport = scheme.partition('+')
    if protocol == 'git':
        # the old regex accepted any git+<transport>; the new schema only
        # allows https/http/ssh/file, so reject others (e.g. git+rsync)
        if transport and transport not in git_transports:
            return None
        return 'git', (transport or None)
    if scheme in direct_schemes:
        return scheme, None
    return None


def migrate(config: ConfigTree) -> None:
    location_base = base + ['location']
    if not config.exists(location_base):
        return

    # already migrated: 'location' is a tagNode, not a leaf of URLs - a forced
    # re-run must not delete and rebuild it
    if config.is_tag(location_base):
        return

    migratable = []
    for value in config.return_values(location_base):
        url = urlsplit(value)
        target = scheme_to_target(url.scheme)
        if target is None:
            # drop unsupported schemes with a warning; log only the scheme, not
            # the URL, which may contain credentials
            print(
                f'Warning: commit-archive: dropping location with unsupported scheme "{url.scheme}"'
            )
            continue
        proto, transport = target
        local_git = proto == 'git' and transport == 'file'
        if not local_git and not url.hostname:
            # every remote protocol uploads to a host, so a URL with no server
            # was never a usable target - drop it instead of writing an invalid
            # location (git+file is local and legitimately has no host)
            print(
                f'Warning: commit-archive: dropping location with no server (scheme "{url.scheme}")'
            )
            continue
        if local_git and not unquote(url.path):
            # git+file has no server: the path IS the repository, and an empty
            # one cannot be represented (an empty leaf cannot be set).
            print(
                'Warning: commit-archive: dropping git file location with no repository path'
            )
            continue
        try:
            # a non-numeric port (the old regex allowed it) raises here; keep
            # the rest of the location and fall back to the protocol default
            port = url.port
        except ValueError:
            print(
                f'Warning: commit-archive: ignoring invalid port for location with scheme "{url.scheme}"'
            )
            port = None
        migratable.append((url, target, port))

    config.delete(location_base)

    for index, (url, (proto, transport), port) in enumerate(migratable, start=1):
        name = f'archive-{index}'
        proto_base = location_base + [name, proto]

        # git over file:// is a local repository: only a path and the 'file'
        # transport, no server/port/credentials.
        if proto == 'git' and transport == 'file':
            config.set(proto_base + ['path'], value=unquote(url.path))
            config.set(proto_base + ['transport'], value='file')
            continue

        config.set(proto_base + ['server'], value=url.hostname)
        if port:
            config.set(proto_base + ['port'], value=str(port))

        # store the decoded path, the builder re-encodes it; keep an escaped
        # slash (%2F) encoded so it is not turned into a path separator
        path = _decode_path(url.path)
        if path and path != '/':
            config.set(proto_base + ['path'], value=path)

        # urlsplit keeps the userinfo percent-encoded; store it decoded, as the
        # URL builder re-encodes when assembling the upload URL
        if proto != 'tftp':
            if url.username:
                config.set(
                    proto_base + ['authentication', 'username'],
                    value=unquote(url.username),
                )
            # ftp/ftps accept a password on its own (they fall back to the
            # REMOTE_USERNAME/anonymous user); every other protocol needs a
            # username, so a lone password is dropped to keep the config valid
            allows_lone_password = proto in ('ftp', 'ftps')
            if url.password and (url.username or allows_lone_password):
                config.set(
                    proto_base + ['authentication', 'password'],
                    value=unquote(url.password),
                )

        # record the git transport whenever the URL specified one explicitly
        # (git+https/git+http/git+ssh) - preserve the user's choice even if it
        # matches the https default. A bare 'git' scheme carries none.
        if proto == 'git' and transport:
            config.set(proto_base + ['transport'], value=transport)

    # mark 'location' as a tagNode
    if migratable:
        config.set_tag(location_base)