diff --git a/ansible.cfg b/ansible.cfg
index c30e8169..b05c9701 100644
--- a/ansible.cfg
+++ b/ansible.cfg
@@ -1,2 +1,3 @@
[defaults]
nocows=1
+log_path=/opt/vmtools/logs/last_run.log
diff --git a/roles/oem/tasks/main.yml b/roles/oem/tasks/main.yml
index b6f29a92..49478947 100644
--- a/roles/oem/tasks/main.yml
+++ b/roles/oem/tasks/main.yml
@@ -32,6 +32,11 @@
path: /etc/skel/Desktop
state: directory
mode: 0750
+- name: Ensure log directory exists
+ file:
+ path: /opt/vmtools/logs
+ state: directory
+ mode: 0755
- name: Copy welcome shortcut to skeleton desktop directory
copy:
src: welcome-to-vm.desktop
diff --git a/roles/task-shortcuts/files/uug-ansible-wrapper b/roles/task-shortcuts/files/uug-ansible-wrapper
deleted file mode 100644
index a56d570f..00000000
--- a/roles/task-shortcuts/files/uug-ansible-wrapper
+++ /dev/null
@@ -1,50 +0,0 @@
-#!/usr/bin/env bash
-
-repo_url="https://github.com/jmunixusers/cs-vm-build"
-
-error_text=$(cat <<- EOF
- ERROR
- There were errors running the playbook. Please try to run it again. If you
- continue to experience issues, please report a bug on
- GitHub
-EOF
-)
-
-success_text=$(cat <<- EOF
- SUCCESS
- The script completed successfully.
-EOF
-)
-
-root_text=$(cat <<- EOF
- WARNING
- It is recommended to not run this tool with root permissions.
-EOF
-)
-
-no_args_text=$(cat <<- EOF
- ERROR
- Not enough informaton was provided.
-EOF
-)
-
-gksudo_msg="This tool makes modifications to your system. In order to run, it requires your password."
-
-if [ -z "$1" ]; then
- zenity --error "${no_args_text}"
- exit 1
-fi
-
-if [ -$UID -eq 0 ]; then
- zenity --warning "${root_text}"
- exit 1
-fi
-
-if gksudo --message "$gksudo_msg" -- ansible-pull --url "$repo_url" --purge -i hosts -t "$1"; then
- zenity --info --text="${success_text}"
- exit_status=0
-else
- zenity --error --text="${error_text}"
- exit_status=1
-fi
-exit $exit_status
diff --git a/roles/task-shortcuts/files/uug_ansible_wrapper.py b/roles/task-shortcuts/files/uug_ansible_wrapper.py
new file mode 100755
index 00000000..36e2c296
--- /dev/null
+++ b/roles/task-shortcuts/files/uug_ansible_wrapper.py
@@ -0,0 +1,359 @@
+#!/usr/bin/env python3
+"""
+ This tools creates a simple GUI for running ansible-pull with a
+ predetermined set of tags. It displays the output from the ansible-pull
+ command in a VTE within the GUI. It allows the user to override some things
+ in a configuration file (~/.config/vm_config). The branch to pull can be
+ overriden by setting FORCE_BRANCH and the URL to pull from can be overriden
+ with FORCE_GIT_URL
+"""
+
+import logging
+import os
+import socket
+import subprocess
+import sys
+
+import gi
+gi.require_version('Gtk', '3.0')
+gi.require_version('Vte', '2.91')
+from gi.repository import Gtk, Vte
+from gi.repository import GLib
+
+# If no tags are passed to ansible-pull, all of them will be run and I am
+# uncertain of the outcome of passing -t with no tags. To avoid this, always
+# ensure that common is run by adding it to this list and disabling the
+# checkbox
+TAGS = ["common"]
+# Map of course names to the Ansible tags
+COURSES = {'CS 101': 'cs101', 'CS 149': 'cs149', 'CS 159': 'cs159',
+ 'CS 261': 'cs261', 'CS 354': 'cs354'}
+USER_CONFIG_PATH = os.path.join(os.environ['HOME'], ".config", "vm_config")
+USER_CONFIG = {}
+CURRENT_CONFIG = {'RELEASE': None, 'URL': None}
+
+
+def main():
+ """Sets up logging and starts the GUI"""
+ # Configure logging. Log to a file and create it if it doesn't exist. If
+ # it cannot be opened, then fall back to logging on the console
+ user_log_file = os.path.join(os.environ['HOME'], ".cache",
+ "uug_ansible_wrapper.log")
+ try:
+ logging.basicConfig(format="%(asctime)s - %(levelname)s: %(message)s",
+ datefmt="%Y-%m-%d-%H-%M",
+ filename=user_log_file,
+ filemode="w+",
+ level=logging.INFO)
+ except OSError:
+ logging.basicConfig(format="%(levelname)s: %(message)s",
+ level=logging.INFO)
+ logging.error("Unable to open log file at %s. Logging on console"
+ " instead", user_log_file)
+
+ # Set the url, release, and user config ahead of showing the window so
+ # they can be displayed in labels
+ parse_user_config()
+ CURRENT_CONFIG['URL'] = get_remote_url()
+ try:
+ CURRENT_CONFIG['RELEASE'] = get_distro_release_name()
+ except ValueError:
+ logging.warning("The branch was unable to be detected.")
+ unable_to_detect_branch()
+
+ # Show the window and ensure when it's closed that the script terminates
+ win = CheckboxWindow()
+ win.connect("delete-event", Gtk.main_quit)
+ win.show_all()
+ Gtk.main()
+
+
+class CheckboxWindow(Gtk.Window):
+ """The main window for the program. Includes a series of checkboxes for
+ courses as well as a VTE to show the output of the Ansible command"""
+
+ def __init__(self):
+ Gtk.Window.__init__(self, title="JMU CS VM Configuration")
+
+ # Attempt to use tux as the icon. If it fails, that's okay
+ try:
+ self.set_icon_from_file("/opt/jmu-tux.svg")
+ except GLib.GError as err:
+ logging.warning("Unable to set Tux icon", exc_info=err)
+
+ self.set_border_width(10)
+
+ # Create a box to contain all elements that will be added to the window
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
+ self.add(vbox)
+
+ label = Gtk.Label("Select the courses you need configured on the VM")
+ label.set_alignment(0.0, 0.0)
+ vbox.pack_start(label, False, False, 0)
+
+ courses_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
+ # This button doesn't do anything. Common is always run
+ refresh = Gtk.CheckButton("Refresh base configuration")
+ refresh.set_tooltip_text("This option is required")
+ refresh.set_active(True)
+ refresh.set_sensitive(False)
+ courses_box.pack_start(refresh, False, False, 0)
+ # Add a checkbox for every course; sorting is necessary because
+ # dictionaries do not guarantee that order is preserved
+ for (course, tag) in sorted(COURSES.items()):
+ checkbox = Gtk.CheckButton(course)
+ checkbox.set_tooltip_text("Configure for %s" % course)
+ checkbox.connect("toggled", self.on_button_toggled, tag)
+ courses_box.pack_start(checkbox, False, False, 0)
+ vbox.pack_start(courses_box, False, False, 0)
+
+ # Add informational labels to the bottom of the window
+ label_box = Gtk.Box(spacing=6)
+ url_label = Gtk.Label("URL: %s" % CURRENT_CONFIG['URL'])
+ label_box.pack_start(url_label, False, False, 6)
+ branch_label = Gtk.Label("Branch: %s" % CURRENT_CONFIG['RELEASE'])
+ label_box.pack_start(branch_label, False, False, 6)
+ vbox.pack_end(label_box, False, False, 0)
+
+ # Add run and cancel buttons
+ button_box = Gtk.Box(spacing=6)
+ self.run_button = Gtk.Button.new_with_label("Run")
+ self.run_button.set_tooltip_text("Configure the VM")
+ self.run_button.connect("clicked", self.on_run_clicked)
+ button_box.pack_start(self.run_button, True, True, 0)
+ self.cancel_button = Gtk.Button.new_with_mnemonic("_Quit")
+ self.cancel_button.connect("clicked", Gtk.main_quit)
+ button_box.pack_end(self.cancel_button, True, True, 0)
+ vbox.pack_end(button_box, False, True, 0)
+
+ # Add the terminal to the window
+ self.terminal = Vte.Terminal()
+ self.terminal.connect("child-exited", self.sub_command_exited)
+ vbox.pack_end(self.terminal, True, True, 0)
+
+ @classmethod
+ def on_button_toggled(cls, button, name):
+ """Adds the name of the button that triggered this call to the list of
+ tags that will be passed to ansible-pull"""
+ if button.get_active():
+ TAGS.append(name)
+ else:
+ TAGS.remove(name)
+
+ def sub_command_exited(self, _, exit_status):
+ """Displays a dialog informing the user whether the gksudo and
+ ansible-pull commands completely successfully or not"""
+ self.cancel_button.set_sensitive(True)
+ self.run_button.set_sensitive(True)
+ if exit_status == 0:
+ success_msg = "Your machine has been configured for: %s" \
+ % (",".join(TAGS))
+ show_dialog(self, Gtk.MessageType.INFO, Gtk.ButtonsType.OK,
+ "Complete", success_msg)
+ logging.info("ansible-pull succeeded")
+ # 65280 should be the exit code if the gksudo dialog is dismissed
+ elif exit_status == 65280:
+ gksudo_err_msg = "Either an incorrect password was entered or" \
+ " the password dialog was closed." \
+ " Please try again"
+ show_dialog(self, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK,
+ "Unable to authenticate", gksudo_err_msg)
+ logging.warning("Unable to authenicate user")
+ else:
+ ansible_err_msg = "There was an error while running the" \
+ " configuration tasks. Please try again." \
+ "\nIf this issue continues to occur, copy" \
+ " /opt/vmtools/logs/last_run.log and" \
+ " create an issue" \
+ % (CURRENT_CONFIG['URL'])
+ show_dialog(self, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK,
+ "Error", ansible_err_msg)
+ logging.error("ansible-pull failed")
+
+ def on_run_clicked(self, _):
+ """Begins the process of running the command in the VTE and disables
+ the run and cancel buttons so that they cannot be used while the
+ command is running"""
+ if not is_online():
+ no_internet_msg = "It appears that you are not able to access" \
+ " the Internet. This tools requires that you" \
+ " be online please check your settings and try" \
+ " again"
+ show_dialog(self, Gtk.MessageType.ERROR, Gtk.ButtonsType.CANCEL,
+ "No Internet connection", no_internet_msg)
+ return
+
+ if not validate_branch():
+ invalid_branch(self)
+ return
+
+ gksudo_msg = "Enter your password to configure your VM" \
+ "\nTo configure your virtual machine, administrator" \
+ " privileges are required."
+
+ self.cancel_button.set_sensitive(False)
+ self.run_button.set_sensitive(False)
+
+ logging.info("Running ansible-pull with flags: %s", ",".join(TAGS))
+
+ self.terminal.spawn_sync(Vte.PtyFlags.DEFAULT,
+ os.environ['HOME'],
+ ["/usr/bin/gksudo", "--message",
+ gksudo_msg, "--",
+ "ansible-pull", "-U", CURRENT_CONFIG['URL'],
+ "-C", CURRENT_CONFIG['RELEASE'],
+ "--purge", "-i", "hosts",
+ "-t", ",".join(TAGS)],
+ [],
+ GLib.SpawnFlags.DO_NOT_REAP_CHILD,
+ None,
+ None,
+ )
+
+
+def show_dialog(parent, dialog_type, buttons_type, header, message):
+ """Shows a dialog to the user with the provided header, message, and
+ buttons. The message is always displated with format_secondary_markup
+ and therefore will passed through Pango. It should be escaped properly
+ """
+ dialog = Gtk.MessageDialog(parent, 0, dialog_type, buttons_type, header)
+ dialog.format_secondary_markup(message)
+ response = dialog.run()
+ dialog.destroy()
+ return response
+
+
+def parse_simple_config(path, data):
+ """Loads the user's configuration into the user_config global variable"""
+ try:
+ with open(path, "r") as config:
+ for line in config:
+ # Allow comments
+ if line.strip().startswith("#"):
+ continue
+ # Ignore any line without an assignment
+ if "=" not in line:
+ continue
+ # Ignore lines with too many/few = signs
+ try:
+ (key, val) = line.split("=")
+ data[key.strip()] = val.strip()
+ except ValueError:
+ logging.warning("Invalid entry in config file: %s", line)
+ continue
+ except FileNotFoundError:
+ logging.info("Ignoring user configuration. It is not present")
+
+
+def parse_user_config():
+ """Loads a user's configuration"""
+ parse_simple_config(USER_CONFIG_PATH, USER_CONFIG)
+
+
+def parse_os_release():
+ """Loads the data in /etc/os-release"""
+ config = {}
+ parse_simple_config("/etc/os-release", config)
+ return config
+
+
+def get_distro_release_name():
+ """Attempts to get the release name of the currently-running OS. It reads
+ /etc/os-release and then regardless of whether or not a release has
+ been found, if FORCE_BRANCH exists in ~/.config/vm_config that will be
+ returned. If nothing is found, a ValueError is raised."""
+ release = ""
+
+ os_release_config = parse_os_release()
+ if 'VERSION_CODENAME' in os_release_config:
+ release = os_release_config['VERSION_CODENAME']
+ else:
+ logging.debug("VERSION_CODENAME is not in /etc/os_release."
+ "Full file contents: %s", os_release_config)
+
+ if 'FORCE_BRANCH' in USER_CONFIG:
+ user_branch = USER_CONFIG['FORCE_BRANCH']
+ if user_branch:
+ release = user_branch
+ else:
+ logging.warning("User set a branch ('%s') but it is invalid",
+ user_branch)
+
+ if release == "" or release == " " or release is None:
+ logging.warning("No valid release was detected")
+ raise ValueError("Version could not be detected")
+
+ return release
+
+
+def get_remote_url():
+ """Checks if the user has specified a FORCE_GIT_URL in their config file.
+ If so, that is returned. Otherwise, the default jmunixusers URL is
+ returned"""
+ if 'FORCE_GIT_URL' in USER_CONFIG:
+ return USER_CONFIG['FORCE_GIT_URL']
+ return "https://github.com/jmunixusers/cs-vm-build"
+
+
+def validate_branch():
+ """Checks the branch passed in against the branches available on remote.
+ Returns true if branch exists on remote. This may be subject to false
+ postivies, but that should not be an issue"""
+ output = subprocess.run(["/usr/bin/git", "ls-remote",
+ CURRENT_CONFIG['URL']],
+ stdout=subprocess.PIPE)
+
+ ls_remote_output = output.stdout.decode("utf-8")
+
+ return CURRENT_CONFIG['RELEASE'] in ls_remote_output
+
+
+def invalid_branch(parent):
+ """Displays a dialog if the branch choses does not exist on the remote"""
+ bad_branch_msg = "The release chosen does not exist at the project URL." \
+ " Please check the settings listed below and try again." \
+ "\nRelease: %(0)s\nURL: %(1)s\nIf you're using a current"\
+ " release of Linux Mint, you may submit"\
+ " an issue requesting support for" \
+ " the release listed above" \
+ % {'0': CURRENT_CONFIG['RELEASE'],
+ '1': CURRENT_CONFIG['URL']}
+ show_dialog(parent, Gtk.MessageType.ERROR, Gtk.ButtonsType.CANCEL,
+ "Invalid Release", bad_branch_msg)
+ return
+
+
+def unable_to_detect_branch():
+ """Displays a dialog to ask the user if they would like to use the master
+ branch. If the user clicks yes, release is set to master. If the user
+ says no, the script exits"""
+ master_prompt = "The version of your OS could not be determined." \
+ " Would you like to use the master branch?" \
+ " This is very dangerous"
+ response = show_dialog(None, Gtk.MessageType.ERROR, Gtk.ButtonsType.YES_NO,
+ "OS detection error", master_prompt)
+ if response != Gtk.ResponseType.YES:
+ logging.info("The user chose not to use master")
+ sys.exit(1)
+ else:
+ CURRENT_CONFIG['RELEASE'] = "master"
+ logging.info("Release set to master")
+
+
+def is_online():
+ """Checks if the user is able to reach a selected hostname."""
+ # Since the user will probably be pulling a significant amount of data
+ # from this host, it should be a decent host to use to check connectivity
+ test_hostname = "packages.linuxmint.com"
+ try:
+ host = socket.gethostbyname(test_hostname)
+ test_connection = socket.create_connection((host, 80), 2)
+ test_connection.close()
+ return True
+ except OSError as err:
+ logging.warning("%s is unreachable.", test_hostname, exc_info=err)
+ return False
+
+
+if __name__ == "__main__":
+ main()
diff --git a/roles/task-shortcuts/tasks/main.yml b/roles/task-shortcuts/tasks/main.yml
index 8a5c580b..73cbfede 100644
--- a/roles/task-shortcuts/tasks/main.yml
+++ b/roles/task-shortcuts/tasks/main.yml
@@ -12,7 +12,7 @@
file_mode: 0644
when: icon_mode == 'user'
- set_fact: bin_path="{{ base_path }}/vmtools/bin"
-- set_fact: uug_ansible_wrapper="{{ bin_path }}/uug-ansible-wrapper"
+- set_fact: uug_ansible_wrapper="{{ bin_path }}/uug_ansible_wrapper.py"
- name: Create VMTools bin
file:
@@ -20,7 +20,7 @@
state: directory
- name: Install Ansible wrapper script
copy:
- src: uug-ansible-wrapper
+ src: uug_ansible_wrapper.py
dest: '{{ uug_ansible_wrapper }}'
mode: 0755
- name: Install JMU Tux icon
@@ -28,10 +28,9 @@
src: jmu-tux.svg
dest: "{{ base_path }}/jmu-tux.svg"
mode: 0644
-- name: Copy shortcuts to skeleton desktop directory
+- name: Copy shortcut to desktop file directory
template:
src: desktop-template.desktop.j2
- dest: "{{ icon_path }}/{{ item.tags }}.desktop"
+ dest: "{{ icon_path }}/jmucs_config.desktop"
mode: "{{ file_mode }}"
- with_items: '{{ supported_courses }}'
diff --git a/roles/task-shortcuts/templates/desktop-template.desktop.j2 b/roles/task-shortcuts/templates/desktop-template.desktop.j2
index 8d4cd655..4d613abc 100644
--- a/roles/task-shortcuts/templates/desktop-template.desktop.j2
+++ b/roles/task-shortcuts/templates/desktop-template.desktop.j2
@@ -1,10 +1,8 @@
[Desktop Entry]
Type=Application
Version=1.0
-Name={{ item.desc }}
-Comment=Configure packages and settings for {{ item.desc }}
-TryExec=ansible-pull
-Exec={{ uug_ansible_wrapper }} "{{ item.tags }}"
+Name=JMU CS VM Configuration
+Comment=Update and configure courses
+Exec={{ uug_ansible_wrapper }}
Icon={{ base_path }}/jmu-tux.svg
-Categories=System;ConsoleOnly;
-Terminal=true
+Categories=System;
diff --git a/roles/task-shortcuts/vars/main.yml b/roles/task-shortcuts/vars/main.yml
index 375267b7..867e2605 100644
--- a/roles/task-shortcuts/vars/main.yml
+++ b/roles/task-shortcuts/vars/main.yml
@@ -1,10 +1,2 @@
---
# vars file for task-shortcuts
-
-supported_courses:
- - { tags: cs101, desc: Install CS101 packages }
- - { tags: cs149, desc: Install CS149 packages }
- - { tags: cs159, desc: Install CS159 packages }
- - { tags: cs261, desc: Install CS261 packages }
- - { tags: cs354, desc: Install CS354 packages }
- - { tags: common, desc: Refresh VM Config }
diff --git a/scripts/oem-build b/scripts/oem-build
index 553c7d39..9a2e4739 100755
--- a/scripts/oem-build
+++ b/scripts/oem-build
@@ -18,7 +18,7 @@ sudo apt-get install --assume-yes ansible git\
|| error "Unable to install ansible and git packages"
user "==> Running ansible OEM playbook"
-# Ensure this runs from the root directory of
+# Ensure this runs from the root directory of the Ansible git directory
cd "$(dirname "$0")"/.. \
|| error "Unable to cd to $(dirname "$0")/.."
ansible-playbook -i hosts -c local -K -t oem oem.yml \