Linux Config Synchronization using GIT
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

506 lines
17 KiB

#!/usr/bin/env python3
import os
import sys
import subprocess
import configparser
import socket
import datetime
import shutil
from pathlib import Path
import stat
import pwd
import grp
CONFIG_SYNC_VERSION = "1.0.0"
CONFIG_SYNC_FILE = "/etc/cfgs.conf"
# -----------------------------------------
# color defintions
# -----------------------------------------
class bcolors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
# -----------------------------------------
# UI helper functions
# -----------------------------------------
def color_message(color, message, colorend=bcolors.ENDC, bold=False):
if bold:
return bcolors.BOLD + color_message(color, message, colorend, False)
return color + message + colorend
def print_color(color, message, colorend=bcolors.ENDC, bold=False):
print(color_message(color, message, colorend))
def check_dependencies():
status = True
try:
subprocess.call(["git", "version"], stdout=subprocess.DEVNULL)
except OSError:
status = False
print_color(bcolors.FAIL, "git cannot be found on your system!")
print("Install git: pacman -S git")
return status
def confirm(message, color=bcolors.WARNING):
status = input(color_message(color, message + " [y/N]: "))
return status and (status == "y" or status == "Y")
def print_splash():
print_color(bcolors.HEADER, "---=== cfgs ===---")
print("Version: " + CONFIG_SYNC_VERSION)
print()
print_color(bcolors.HEADER, "---=== USAGE ===---")
print("cfgs init")
print("cfgs add <file path>")
print("cfgs remove <file path>")
print("cfgs store")
print("cfgs restore")
sys.exit(1)
def init_dialog():
config = get_config()
print_color(bcolors.HEADER, "---=== Welcome to cfgs! ===---")
print()
if config['DEFAULT']['INITIALIZED']:
if not confirm("WARNING: cfgs has already been initialized! Do you really want to change the configuration?"):
print("Skipping new initialization!")
sys.exit(0)
local_path = input(color_message(bcolors.OKBLUE, "Enter the local storage directory for configuration files [" + config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'] + "]: "))
if not local_path:
local_path = config['DEFAULT']['LOCAL_STORAGE_DIRECTORY']
config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'] = local_path
remote_repo = input(color_message(bcolors.OKBLUE, "Enter the remote repository url [" + config['GIT']['REMOTE_REPOSITORY'] + "]: "))
if not remote_repo:
remote_repo = config['GIT']['REMOTE_REPOSITORY']
config['GIT']['REMOTE_REPOSITORY'] = remote_repo
git_ssh_key = input(color_message(bcolors.OKBLUE, "Enter the path to the ssh key to use [" + config['GIT']['SSHKEY'] + "]: "))
if not git_ssh_key:
git_ssh_key = config['GIT']['SSHKEY']
config['GIT']['SSHKEY'] = git_ssh_key
git_user = input(color_message(bcolors.OKBLUE, "Enter the git user name (displayname) [" + config['GIT']['USER'] + "]: "))
if not git_user:
git_user = config['GIT']['USER']
config['GIT']['USER'] = git_user
git_email = input(color_message(bcolors.OKBLUE, "Enter the git user email address [" + config['GIT']['EMAIL'] + "]: "))
if not git_email:
git_email = config['GIT']['EMAIL']
config['GIT']['EMAIL'] = git_email
config['DEFAULT']['INITIALIZED'] = "True"
update_config(config)
# -----------------------------------------
# local config helper functions
# -----------------------------------------
def validate_or_create_config(config):
if not os.path.exists(CONFIG_SYNC_FILE):
print_color(bcolors.OKBLUE, "Creating default configuration file: " + CONFIG_SYNC_FILE)
config['DEFAULT'] = {}
config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'] = "/opt/cfgs/storage"
config['DEFAULT']['INITIALIZED'] = ""
config['GIT'] = {}
config['GIT']['REMOTE_REPOSITORY'] = ""
config['GIT']['USER'] = "cfgs"
config['GIT']['EMAIL'] = "cfgs@" + socket.gethostname()
config['GIT']['SSHKEY'] = str(Path.home()) + "/.ssh/id_rsa"
update_config(config)
else:
config.read(CONFIG_SYNC_FILE)
return config
def get_config():
config = configparser.ConfigParser()
config = validate_or_create_config(config)
return config
def update_config(config):
try:
config.write(open(CONFIG_SYNC_FILE, 'w'))
except PermissionError as e:
print_color(bcolors.FAIL, "Unable to write config file! Error: " + e.strerror)
sys.exit(1)
except Exception as e:
print_color(bcolors.FAIL, "Unable to write config file! Error: " + str(e))
sys.exit(1)
def create_initialization_file(path):
try:
with open(path + "/cfgs.info", mode='w') as file:
file.write('cfgs repo initialized: %s. host: %s\n' % ( datetime.datetime.now(), socket.gethostname() ))
except PermissionError as e:
print_color(bcolors.FAIL, "Unable to write initialization file! Error: " + e.strerror)
sys.exit(1)
except Exception as e:
print_color(bcolors.FAIL, "Unable to write initialization file! Error: " + str(e))
sys.exit(1)
try:
open(path + "/cfgs.db", mode='a').close()
except PermissionError as e:
print_color(bcolors.FAIL, "Unable to write database file! Error: " + e.strerror)
sys.exit(1)
except Exception as e:
print_color(bcolors.FAIL, "Unable to write database file! Error: " + str(e))
sys.exit(1)
# -----------------------------------------
# local database helper functions
# -----------------------------------------
def add_file_to_db(file_path):
config = get_config()
try:
insert = True
with open(config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'] + "/cfgs.db", "r+") as search:
for line in search:
line = line.rstrip() # remove '\n' at end of line
if file_path == line:
insert = False
if insert:
with open(config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'] + "/cfgs.db", mode='a') as file:
file.write('%s\n' % (file_path))
except PermissionError as e:
print_color(bcolors.FAIL, "Unable to write db file! Error: " + e.strerror)
sys.exit(1)
except Exception as e:
print_color(bcolors.FAIL, "Unable to write db file! Error: " + str(e))
sys.exit(1)
def delete_file_from_db(file_path):
config = get_config()
try:
with open(config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'] + "/cfgs.db", "r+") as search:
lines = search.readlines()
search.seek(0)
for line in lines:
if line.rstrip() != file_path:
search.write(line.rstrip() + "\n")
search.truncate()
except PermissionError as e:
print_color(bcolors.FAIL, "Unable to write db file! Error: " + e.strerror)
sys.exit(1)
except Exception as e:
print_color(bcolors.FAIL, "Unable to write db file! Error: " + str(e))
sys.exit(1)
# -----------------------------------------
# git helper functions
# -----------------------------------------
def git_setup_ssh_environment():
config = get_config()
os.environ['GIT_SSH_COMMAND'] = "ssh -i " + config['GIT']['SSHKEY']
def git_is_repo(path):
return subprocess.call(["git", "branch"], stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, cwd=path) == 0
def git_set_author(path, user, email):
status = subprocess.call(["git", "config", "user.name", user], stderr=subprocess.STDOUT, stdout=subprocess.DEVNULL, cwd=path) == 0
status |= subprocess.call(["git", "config", "user.email", email], stderr=subprocess.STDOUT, stdout=subprocess.DEVNULL, cwd=path) == 0
return status
def git_set_upstream(path, remote_url):
# make sure that remote origin does not exist
status = subprocess.call(["git", "remote", "rm", "origin"], stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, cwd=path) == 0
# now add new origin
status |= subprocess.call(["git", "remote", "add", "origin", remote_url], cwd=path) == 0
return status
def git_init_repo(path):
status = subprocess.call(["git", "init"], cwd=path) == 0
return status
def git_push(path, message = "configuration update"):
status = subprocess.call(["git", "add", "-A"], cwd=path) == 0
status |= subprocess.call(["git", "commit", "-a", "-m", message], cwd=path) == 0
status |= subprocess.call(["git", "push", "-u", "origin", "master"], cwd=path) == 0
return status
def git_pull(path):
status = subprocess.call(["git", "pull", "origin", "master"], cwd=path) == 0
return status
def git_check_status(status):
config = get_config()
if not status:
print_color(bcolors.FAIL, "FATAL: Git operation failed! Repository has to be recovered manually!", bold=True)
print_color(bcolors.FAIL, "FATAL: Repository path: " + config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'])
sys.exit(1)
# -----------------------------------------
# main business logic
# -----------------------------------------
def init_local_repo():
config = get_config()
print("Setting up local repository.")
if not os.path.exists(config['DEFAULT']['LOCAL_STORAGE_DIRECTORY']):
print_color(bcolors.OKBLUE, "Creating new directory: " + config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'])
try:
os.makedirs(config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'])
except OSError as e:
print_color(bcolors.FAIL, "Failed to create local repository directory: " + e.strerror)
sys.exit(1)
# init git repo
if not git_is_repo(config['DEFAULT']['LOCAL_STORAGE_DIRECTORY']):
git_init_repo(config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'])
git_check_status(git_set_author(config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'], config['GIT']['USER'], config['GIT']['EMAIL']))
git_check_status(git_set_upstream(config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'], config['GIT']['REMOTE_REPOSITORY']))
git_setup_ssh_environment()
git_check_status(git_pull(config['DEFAULT']['LOCAL_STORAGE_DIRECTORY']))
create_initialization_file(config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'])
git_check_status(git_push(config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'], "cfgs initialized"))
git_check_status(git_pull(config['DEFAULT']['LOCAL_STORAGE_DIRECTORY']))
print_color(bcolors.OKGREEN, "cfgs initialization successfull!")
print_color(bcolors.OKBLUE, "You can now use cfgs to keep your config files up to date.")
def add_file(filepath):
config = get_config()
abs_path = os.path.abspath(filepath)
if not os.path.exists(abs_path):
print_color(bcolors.WARNING, "Invalid file, skipping!")
sys.exit(1)
else:
print_color(bcolors.OKBLUE, "Adding '" + abs_path + "' to cfgs...")
local_path = config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'] + abs_path
if os.path.exists(local_path):
print_color(bcolors.WARNING, "File is already registered to cfgs, skipping!")
sys.exit(1)
else:
target_directory = os.path.abspath(os.path.join(local_path, os.pardir))
if not os.path.exists(target_directory):
os.makedirs(target_directory)
file_stat = os.stat(abs_path)
file_permissions = file_stat[stat.ST_MODE]
file_owner = file_stat[stat.ST_UID]
file_group = file_stat[stat.ST_GID]
file_owner_name = pwd.getpwuid(file_owner).pw_name
file_group_name = grp.getgrgid(file_group).gr_name
try:
with open(local_path + ".cfgs", mode='w') as file:
file.write('%s;%s;%s;%s;%s;%s' % ( file_permissions, file_owner, file_group, file_owner_name, file_group_name, socket.gethostname() ))
except PermissionError as e:
print_color(bcolors.FAIL, "Unable to write stat file! Error: " + e.strerror)
sys.exit(1)
except Exception as e:
print_color(bcolors.FAIL, "Unable to write stat file! Error: " + str(e))
sys.exit(1)
shutil.copy2(abs_path, local_path)
os.chown(local_path, file_owner, file_group)
os.remove(abs_path)
os.symlink(local_path, abs_path)
os.chown(abs_path, file_owner, file_group, follow_symlinks=False)
add_file_to_db(abs_path)
print_color(bcolors.OKGREEN, "File added to cfgs!")
print_color(bcolors.OKBLUE, "Use cfgs store to push the file to the remote repository!")
def remove_file(filepath):
config = get_config()
abs_path = os.path.abspath(filepath)
local_path = config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'] + abs_path
if not os.path.exists(local_path):
print_color(bcolors.WARNING, "File not registered to cfgs, skipping!")
sys.exit(1)
else:
print_color(bcolors.OKBLUE, "Removing '" + abs_path + "' from cfgs...")
target_directory = os.path.abspath(os.path.join(abs_path, os.pardir))
if not os.path.exists(target_directory):
os.makedirs(target_directory)
if os.path.exists(abs_path):
if not confirm("Do you really want to override the local file '" + abs_path + "'?", bcolors.OKBLUE):
print_color(bcolors.WARNING, "Skipping removal process!")
sys.exit(0)
else:
os.remove(abs_path)
shutil.copy2(local_path, abs_path)
file_stat = os.stat(local_path)
file_permissions = file_stat[stat.ST_MODE]
file_owner = file_stat[stat.ST_UID]
file_group = file_stat[stat.ST_GID]
os.chown(abs_path, file_owner, file_group)
os.chmod(abs_path, file_permissions)
os.remove(local_path)
os.remove(local_path + ".cfgs")
delete_file_from_db(abs_path)
print_color(bcolors.OKGREEN, "File removed from cfgs!")
print_color(bcolors.OKBLUE, "Use cfgs store to push the file to the remote repository!")
def store():
config = get_config()
git_setup_ssh_environment()
git_push(config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'])
print_color(bcolors.OKGREEN, "cfgs store successfull!")
print_color(bcolors.OKBLUE, "You can now restore the configuration on your other systems.")
def update_local_metadata():
config = get_config()
with open(config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'] + "/cfgs.db", "r") as search:
for line in search:
abs_path = line.rstrip() # remove '\n' at end of line
local_path = config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'] + abs_path
if not os.path.exists(local_path):
print_color(bcolors.WARNING, "Invalid file in database, skipping! (" + abs_path + ")")
else:
with open(local_path + ".cfgs", "r") as metadata_file:
metadata_string = metadata_file.readline().rstrip()
metadata = metadata_string.split(";")
file_permissions = int(metadata[0])
file_owner = int(metadata[1])
file_group = int(metadata[2])
os.chmod(local_path, file_permissions)
os.chown(local_path, file_owner, file_group)
def restore():
config = get_config()
git_setup_ssh_environment()
git_pull(config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'])
update_local_metadata()
if not confirm("Do you really want to restore files from the repository? Local files will be overwritten!"):
print_color(bcolors.WARNING, "Skipping restore process!")
sys.exit(0)
else:
with open(config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'] + "/cfgs.db", "r") as search:
for line in search:
abs_path = line.rstrip() # remove '\n' at end of line
local_path = config['DEFAULT']['LOCAL_STORAGE_DIRECTORY'] + abs_path
if not os.path.exists(local_path):
print_color(bcolors.WARNING, "Invalid file, skipping!")
else:
target_directory = os.path.abspath(os.path.join(abs_path, os.pardir))
if not os.path.exists(target_directory):
os.makedirs(target_directory)
if os.path.exists(abs_path):
os.remove(abs_path)
os.symlink(local_path, abs_path)
file_stat = os.stat(local_path)
file_owner = file_stat[stat.ST_UID]
file_group = file_stat[stat.ST_GID]
os.chown(abs_path, file_owner, file_group, follow_symlinks=False)
print_color(bcolors.OKGREEN, "cfgs restore successfull!")
print_color(bcolors.OKBLUE, "Configuration files have been updated. A reboot might be required.")
# -----------------------------------------
# main entry point
# -----------------------------------------
def main(argv):
if not check_dependencies():
sys.exit(1)
if len(argv) == 2:
arg = argv[1]
if arg == "init":
init_dialog()
init_local_repo()
sys.exit(0)
elif arg == "store":
store()
elif arg == "restore":
restore()
else:
print_splash()
elif len(argv) == 3:
arg = argv[1]
config_file = argv[2]
if arg == "add":
add_file(config_file)
elif arg == "remove":
remove_file(config_file)
else:
print_splash()
else:
print_splash()
if __name__ == "__main__":
main(sys.argv)