Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

T2405: add Git support to commit-archive #2241

Merged
merged 5 commits into from Nov 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions debian/control
Expand Up @@ -230,6 +230,9 @@ Depends:
lcdproc,
lcdproc-extra-drivers,
# End "system lcd"
# For "system config-management commit-archive"
git
c-po marked this conversation as resolved.
Show resolved Hide resolved
# End "system config-management commit-archive"
# For firewall
libndp-tools,
libnetfilter-conntrack3,
Expand Down
1 change: 1 addition & 0 deletions interface-definitions/system-config-mgmt.xml.in
Expand Up @@ -22,6 +22,7 @@
</valueHelp>
<constraint>
<validator name="url --file-transport"/>
yunzheng marked this conversation as resolved.
Show resolved Hide resolved
<regex>(ssh|git|git\+(\w+)):\/\/.*</regex>
</constraint>
<multi/>
</properties>
Expand Down
20 changes: 17 additions & 3 deletions python/vyos/config_mgmt.py
Expand Up @@ -22,10 +22,11 @@
from typing import Optional, Tuple, Union
from filecmp import cmp
from datetime import datetime
from textwrap import dedent
from textwrap import dedent, indent
c-po marked this conversation as resolved.
Show resolved Hide resolved
from pathlib import Path
from tabulate import tabulate
from shutil import copy, chown
from urllib.parse import urlsplit, urlunsplit

from vyos.config import Config
from vyos.configtree import ConfigTree, ConfigTreeError, show_diff
Expand Down Expand Up @@ -377,9 +378,22 @@ def commit_archive(self):
remote_file = f'config.boot-{hostname}.{timestamp}'
source_address = self.source_address

if self.effective_locations:
print("Archiving config...")
for location in self.effective_locations:
upload(archive_config_file, f'{location}/{remote_file}',
source_host=source_address)
url = urlsplit(location)
_, _, netloc = url.netloc.rpartition("@")
redacted_location = urlunsplit(url._replace(netloc=netloc))
print(f" {redacted_location}", end=" ", flush=True)
try:
upload(archive_config_file, f'{location}/{remote_file}',
source_host=source_address, raise_error=True)
print("OK")
except Exception as e:
print("FAILED!")
print()
print(indent(str(e), " > "))
print()

# op-mode functions
#
Expand Down
131 changes: 124 additions & 7 deletions python/vyos/remote.py
Expand Up @@ -14,6 +14,7 @@
# License along with this library. If not, see <http://www.gnu.org/licenses/>.

import os
import pwd
import shutil
import socket
import ssl
Expand All @@ -22,6 +23,9 @@
import tempfile
import urllib.parse

from contextlib import contextmanager
from pathlib import Path

from ftplib import FTP
from ftplib import FTP_TLS

Expand All @@ -37,11 +41,22 @@
from vyos.utils.io import is_interactive
from vyos.utils.io import print_error
from vyos.utils.misc import begin
from vyos.utils.process import cmd
from vyos.utils.process import cmd, rc_cmd
c-po marked this conversation as resolved.
Show resolved Hide resolved
from vyos.version import get_version

CHUNK_SIZE = 8192

@contextmanager
def umask(mask: int):
"""
Context manager that temporarily sets the process umask.
"""
oldmask = os.umask(mask)
try:
yield
finally:
os.umask(oldmask)

class InteractivePolicy(MissingHostKeyPolicy):
"""
Paramiko policy for interactively querying the user on whether to proceed
Expand Down Expand Up @@ -310,35 +325,137 @@ def upload(self, location: str):
with open(location, 'rb') as f:
cmd(f'{self.command} -T - "{self.urlstring}"', input=f.read())

class GitC:
def __init__(self,
url,
progressbar=False,
check_space=False,
source_host=None,
source_port=0,
timeout=10,
c-po marked this conversation as resolved.
Show resolved Hide resolved
):
self.command = 'git'
self.url = url
self.urlstring = urllib.parse.urlunsplit(url)
if self.urlstring.startswith("git+"):
self.urlstring = self.urlstring.replace("git+", "", 1)

def download(self, location: str):
raise NotImplementedError("not supported")

@umask(0o077)
def upload(self, location: str):
scheme = self.url.scheme
_, _, scheme = scheme.partition("+")
netloc = self.url.netloc
url = Path(self.url.path).parent
with tempfile.TemporaryDirectory(prefix="git-commit-archive-") as directory:
# Determine username, fullname, email for Git commit
pwd_entry = pwd.getpwuid(os.getuid())
user = pwd_entry.pw_name
name = pwd_entry.pw_gecos.split(",")[0] or user
fqdn = socket.getfqdn()
email = f"{user}@{fqdn}"

# environment vars for our git commands
env = {
"GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": name,
"GIT_AUTHOR_EMAIL": email,
"GIT_COMMITTER_NAME": name,
"GIT_COMMITTER_EMAIL": email,
}

# build ssh command for git
ssh_command = ["ssh"]

# if we are not interactive, we use StrictHostKeyChecking=yes to avoid any prompts
if not sys.stdout.isatty():
ssh_command += ["-o", "StrictHostKeyChecking=yes"]

env["GIT_SSH_COMMAND"] = " ".join(ssh_command)

# git clone
path_repository = Path(directory) / "repository"
scheme = f"{scheme}://" if scheme else ""
rc, out = rc_cmd(
[self.command, "clone", f"{scheme}{netloc}{url}", str(path_repository), "--depth=1"],
env=env,
shell=False,
)
if rc:
raise Exception(out)

# git add
filename = Path(Path(self.url.path).name).stem
dst = path_repository / filename
shutil.copy2(location, dst)
rc, out = rc_cmd(
[self.command, "-C", str(path_repository), "add", filename],
env=env,
shell=False,
)

# git commit -m
commit_message = os.environ.get("COMMIT_COMMENT", "commit")
rc, out = rc_cmd(
[self.command, "-C", str(path_repository), "commit", "-m", commit_message],
env=env,
shell=False,
)

# git push
rc, out = rc_cmd(
[self.command, "-C", str(path_repository), "push"],
env=env,
shell=False,
)
if rc:
raise Exception(out)


def urlc(urlstring, *args, **kwargs):
"""
Dynamically dispatch the appropriate protocol class.
"""
url_classes = {'http': HttpC, 'https': HttpC, 'ftp': FtpC, 'ftps': FtpC, \
'sftp': SshC, 'ssh': SshC, 'scp': SshC, 'tftp': TftpC}
url_classes = {
"http": HttpC,
"https": HttpC,
"ftp": FtpC,
"ftps": FtpC,
"sftp": SshC,
"ssh": SshC,
"scp": SshC,
"tftp": TftpC,
"git": GitC,
}
url = urllib.parse.urlsplit(urlstring)
scheme, _, _ = url.scheme.partition("+")
try:
return url_classes[url.scheme](url, *args, **kwargs)
return url_classes[scheme](url, *args, **kwargs)
except KeyError:
raise ValueError(f'Unsupported URL scheme: "{url.scheme}"')
raise ValueError(f'Unsupported URL scheme: "{scheme}"')

def download(local_path, urlstring, progressbar=False, check_space=False,
def download(local_path, urlstring, progressbar=False, raise_error=False, check_space=False,
source_host='', source_port=0, timeout=10.0):
try:
progressbar = progressbar and is_interactive()
urlc(urlstring, progressbar, check_space, source_host, source_port, timeout).download(local_path)
except Exception as err:
if raise_error:
raise
print_error(f'Unable to download "{urlstring}": {err}')
except KeyboardInterrupt:
print_error('\nDownload aborted by user.')

def upload(local_path, urlstring, progressbar=False,
def upload(local_path, urlstring, progressbar=False, raise_error=False,
source_host='', source_port=0, timeout=10.0):
try:
progressbar = progressbar and is_interactive()
urlc(urlstring, progressbar, source_host, source_port, timeout).upload(local_path)
except Exception as err:
if raise_error:
raise
print_error(f'Unable to upload "{urlstring}": {err}')
except KeyboardInterrupt:
print_error('\nUpload aborted by user.')
Expand Down