From a46c35e6450f59010759eda3d6324c2fe624e6db Mon Sep 17 00:00:00 2001
From: Adam Lewenberg <adamhl@stanford.edu>
Date: Fri, 4 Sep 2020 17:04:10 -0700
Subject: [PATCH] Merge changes from adamhl's refactoring of tools scripts

---
 README.md     |  87 +++++++++++-
 add_user      |   3 +
 add_user.rb   | 111 ---------------
 del_user      |   3 +
 del_user.rb   |  70 ----------
 list_users    |   3 +
 manage-user   | 365 ++++++++++++++++++++++++++++++++++++++++++++++++++
 refresh_users |   3 +
 8 files changed, 460 insertions(+), 185 deletions(-)
 create mode 100644 add_user
 delete mode 100755 add_user.rb
 create mode 100644 del_user
 delete mode 100755 del_user.rb
 create mode 100755 list_users
 create mode 100755 manage-user
 create mode 100755 refresh_users

diff --git a/README.md b/README.md
index 80c7d0c..fc67b65 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,9 @@
+[[_TOC_]]
+
 Helper scripts for Users module
 -------------------------------
 
-Ruby scripts to add and remove users from "users" module hirea datasource. POSIX attributes
+Ruby scripts to add and remove users from "users" module hiera datasource. POSIX attributes
 of a user account are matching ones in LDAP. Ruby has been chosen because it does not require
 installation of any additional stuff (modules, packages, etc.) on a Mac PAW.
 
@@ -16,19 +18,96 @@ installation of any additional stuff (modules, packages, etc.) on a Mac PAW.
 Add a user
 
 ```
-./add_user.rb sunet_id
+./add_user sunet_id
 ```
 
 Delete user:
 
 ```
-./del_user.rb sunet_id
+./del_user sunet_id
+```
+
+List users:
+
+```
+./list_users sunet_id
+```
+
+Refresh users:
+
+```
+./refresh_users sunet_id
 ```
 
+The above are all wrapper scripts around `manage-user` and take the same
+options as `manage-user`. For more options, see the section "Advanced
+options" below.
+
 ## Notes
 
-* The script also determines if a user has a base or fully sponsored SUNet account. 
+* The script also determines if a user has a base or fully sponsored SUNet account.
 If a user has a base account, i.e. no AFS space, the script forces his home directory
 in hiera data source to be set to /home/sunet_id. Users module relies on this
 information to automatically put base account homes locally and create .k5login
 files for them.
+
+## Configuration
+
+You can set script configuration using command-line options or by using
+the file `manage-user.yml`. The file `manage-user.yml` _must_ be in the
+same directory as the `manage-user` script.  The configuration options you
+can set are `classname`, `git-commit`, and `verbose`. Example:
+```
+---
+# ./manage-user.yml
+# Recognized configuration options:
+#
+# - classname
+# - git-commit
+# - verbose
+
+# Where to find the user data:
+classname:  defaults::iedo_users
+
+# Set git-commit to true to do a git auto-commit, false otherwise:
+git-commit: false
+
+# Set verbose to true to get more verbose output:
+verbose: true
+```
+
+Note: If you have an option in the `manage-user.yml` configuration file
+_and_ you set the same configuration option via a command-line option, the
+command-line option setting will be the one used.
+
+## Advanced options
+
+By default, the scripts assume that the YAML file where the
+user information is stored is in `modules/users/data/common.yaml` in this format:
+```
+users::stanford_users:
+  adamhl:
+    uid: 12345
+    gid: 37
+    comment: Adam Henry Lewenberg
+    home: "/afs/ir/users/a/d/adamhl"
+    shell: "/bin/tcsh"
+  ...
+```
+
+If you keep this file in a _different_ location, use the `classname` configuration setting in
+`manage-user.yml` (or the `-c` command-line option). For example:
+```
+$ ./add-user adamhl -c defaults::iedo_users
+```
+will look for the file `modules/defaults/data/common.yaml` which should have the format
+```
+defaults::iedo_users:
+  adamhl:
+    uid: 12345
+    gid: 37
+    comment: Adam Henry Lewenberg
+    home: "/afs/ir/users/a/d/adamhl"
+    shell: "/bin/tcsh"
+  ...
+```
diff --git a/add_user b/add_user
new file mode 100644
index 0000000..10829da
--- /dev/null
+++ b/add_user
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+./manage-user add $@
diff --git a/add_user.rb b/add_user.rb
deleted file mode 100755
index 01d44b0..0000000
--- a/add_user.rb
+++ /dev/null
@@ -1,111 +0,0 @@
-#!/usr/bin/env ruby
-
-# Adds a user to users::stanford_users hash in "users" module hiera
-# common.yaml data source. 
-#
-# NOTE: Since the user data in hiera is a hash, output of the tool
-#       is not sorted. Every time the script is used, the data
-#       source is rewritten with the user list in a different order.
-#
-# NOTE: The code assumes that the script is located in "tools" subdirectory
-#       in the root of the repo.
-
-
-require 'yaml'
-require 'pathname'
-
-if ARGV.empty?
-	puts 'Usage: add_user sunet_id'
-	exit
-end
-
-sunetid = ARGV.shift
-
-# determine where the root of the repo is
-# assuming that this script is always in "tools" subdirectory
-# of ther repo's root.
-repo_root = Pathname.new(__dir__).parent()
-
-# figure out a path to the data file
-data_source = repo_root.join('modules/users/data/common.yaml')
-unless File.exists?(data_source)
-	puts "Unable to locate data source"
-	puts "at #{data_source.to_s}."
-	exit
-end
-
-# read the contents of the hiera data source
-hiera_data = YAML.load_file(data_source)
-
-# check if the user already exists in the data source
-if hiera_data['users::stanford_users'].has_key?(sunetid)
-	puts "The user #{sunetid} is already known."
-	exit
-end
-
-# lookup a u information in lsdb
-account = `remctl lsdb user show #{sunetid}`
-
-# if a remctl command failed, then there is no ticket or remctl
-# client is not installed.
-unless $?.success?
-	puts "Please make sure remctl is installed, you have a valid kerberos ticket,"
-	puts "and you have the rights to execute remctl lsdb queries."
-	exit
-end
-
-# if sunetid is not found
-if account.include? "not found in database"
-	puts "#{sunetid} is not a valid SUnet ID."
-	exit
-end
-
-# create a hash with all user attributes
-u = Hash.new
-account.each_line do |line|
-	attrib, val = line.strip.split(/:\s*/, 2)
-	next if val.nil? or val.empty?
-	u[attrib] = val unless u.has_key?(attrib)
-end
-
-# add user attributes from passwd string
-u['pwd'], u['uid'], u['gid'], u['gecos'], u['home'], u['shell'] = u[sunetid].split(/:/)
-
-# if there is no attribute 'Services' or there is no 'afs' value there
-# then the user has a base sponsored sunet id or inactive
-unless u.has_key?('Services') and u['Services'].include?('afs')
-	u['home'] = "/home/#{sunetid}"
-end
-
-# add user to hiera data source
-hiera_data['users::stanford_users'][sunetid] = {
-	'uid' => u['uid'],
-	'gid' => u['gid'],
-	'comment' => u['gecos'],
-	'home' => u['home'],
-	'shell' => u['shell']
-}
-
-# save data source as yaml
-File.open(data_source, 'w') {|f| f.write hiera_data.to_yaml }
-
-# commit the change to git, watch for errors
-if system('git add ' + data_source.to_s)
-	unless system('git commit -m "Add a user \"' + sunetid + '\" to users module data source."')
-		puts "Error committing the change to git. Please do it manually."
-		exit
-	end
-else
-	puts "Error adding the data source file to git. Please add and commit manually."
-	exit
-end
-
-puts <<-EOS
-
-User "#{sunetid}" has been successfully added. The commit has not yet been
-pushed to the remote. To complete this change, run:
-
-	git push
-
-EOS
-
diff --git a/del_user b/del_user
new file mode 100644
index 0000000..ca9eb1e
--- /dev/null
+++ b/del_user
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+./manage-user delete $@
diff --git a/del_user.rb b/del_user.rb
deleted file mode 100755
index 5ff1fa3..0000000
--- a/del_user.rb
+++ /dev/null
@@ -1,70 +0,0 @@
-#!/usr/bin/env ruby
-
-# Deletes a user from users::stanford_users hash in "users" module hiera
-# common.yaml data source. 
-#
-# NOTE: Since the user data in hiera is a hash, output of the tool
-#       is not sorted. Every time the script is used, the data
-#       source is rewritten with the user list in a different order.
-#
-# TODO: Sort the user hash when output to the file.
-
-require 'yaml'
-require 'pathname'
-
-if ARGV.empty?
-	puts 'Usage: del_user sunet_id'
-	exit
-end
-
-sunetid = ARGV.shift
-
-# determine where the root of the repo is
-# assuming that this script is always in "tools" subdirectory
-# of ther repo's root.
-repo_root = Pathname.new(__dir__).parent()
-
-# figure out a path to the data file
-data_source = repo_root.join('modules/users/data/common.yaml')
-unless File.exists?(data_source)
-	puts "Unable to locate data source"
-	puts "at #{data_source.to_s}."
-	exit
-end
-
-# read the contents of the hiera data source
-hiera_data = YAML.load_file(data_source)
-
-# check if the user already exists in the data source
-if hiera_data['users::stanford_users'].has_key?(sunetid)
-	just_deleted = hiera_data['users::stanford_users'].delete(sunetid)
-	# puts 'Deleted the following data from hiera:'
-	# puts just_deleted.to_yaml
-else
-	puts "The user #{sunetid} is not in hiera data. Nothing to do."
-	exit
-end
-
-# save data source as yaml
-File.open(data_source, 'w') {|f| f.write hiera_data.to_yaml }
-
-# commit the change to git, watch for errors
-if system('git add ' + data_source.to_s)
-	unless system('git commit -m "Delete a user \"' + sunetid + '\" from the users module data source."')
-		puts "Error committing the change to git. Please do it manually."
-		exit
-	end
-else
-	puts "Error adding the data source file to git. Please add and commit manually."
-	exit
-end
-
-puts <<-EOS
-
-User "#{sunetid}" has been successfully deleted. The commit has not yet been
-pushed to the remote. To complete this change, run:
-
-	git push
-
-EOS
-
diff --git a/list_users b/list_users
new file mode 100755
index 0000000..fdb188e
--- /dev/null
+++ b/list_users
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+./manage-user list $@
diff --git a/manage-user b/manage-user
new file mode 100755
index 0000000..9b9b8ef
--- /dev/null
+++ b/manage-user
@@ -0,0 +1,365 @@
+#!/usr/bin/env ruby
+
+# Adds a user to users::stanford_users hash in "users" module hiera
+# common.yaml data source.
+#
+# NOTE: Since the user data in hiera is a hash, output of the tool
+#       is not sorted. Every time the script is used, the data
+#       source is rewritten with the user list in a different order.
+#
+# NOTE: The code assumes that the script is located in "tools" subdirectory
+#       in the root of the repo.
+
+
+require 'yaml'
+require 'pathname'
+require 'optparse'
+
+def exit_with_error(msg)
+  puts "error: #{msg}"
+  exit 1
+end
+
+def exit_without_error(msg)
+  puts msg
+  exit 0
+end
+
+########################################################################
+########################################################################
+class ManageUser
+
+  attr_accessor :verbose
+  attr_accessor :dryrun
+  attr_accessor :hiera_data
+  attr_accessor :git_commit
+  attr_accessor :data_source
+  attr_accessor :classname
+
+  def initialize()
+    @verbose     = false
+    @dryrun      = true
+    @data_source = nil
+    @git_commit  = false
+    @classname   = 'users::stanford_users'
+    @hiera_data  = nil
+    @action      = nil
+    @config_file = File.join(__dir__, 'manage-user.yml')
+
+    self.read_configuration()
+  end
+
+  def progress(msg)
+    if (@verbose) then
+      puts "[progress] #{msg}"
+    end
+  end
+
+  def dryrun_progress(msg)
+    puts "[dry run] #{msg}"
+  end
+
+  def read_configuration()
+    config_data = YAML.load_file(@config_file)
+
+    if (config_data.key?('classname')) then
+      self.classname = config_data['classname']
+    end
+
+    if (config_data.key?('git-commit')) then
+      self.git_commit = config_data['git-commit']
+    end
+
+    if (config_data.key?('verbose')) then
+      self.verbose = config_data['verbose']
+    end
+  end
+
+  def set_data_source()
+    # Determine where the root of the repo is assuming that this script is
+    # always in "tools" subdirectory of the repo's root.
+    repo_root = Pathname.new(__dir__).parent()
+    self.progress("repo_root is #{repo_root}")
+
+    # figure out a path to the data file. We want the _first_ part of
+    # the class name.
+    prefix_regex = %r{^(?<prefix>[^:]+)::(.*)$}
+    if (matches = @classname.match(prefix_regex)) then
+      classname_prefix = matches[:prefix]
+      self.progress("classname prefix is #{classname_prefix}")
+    else
+      exit_with_error("could not determine class prefix for '#{@classname}'")
+    end
+
+    @data_source = repo_root.join("modules/#{classname_prefix}/data/common.yaml")
+    self.progress("data_source is #{@data_source}")
+    unless File.exist?(@data_source)
+      exit_with_error("Unable to locate data source at #{@data_source.to_s}")
+    end
+
+    self.set_hiera_data()
+
+    return @data_source
+  end
+
+  def set_hiera_data()
+    @hiera_data = YAML.load_file(@data_source)
+  end
+
+  def user_already_known?(sunetid)
+    return @hiera_data[@classname].key?(sunetid)
+  end
+
+  def get_user_info_from_lsdb(sunetid)
+    # lookup a user's information in lsdb via a remctl call.
+    account = `remctl lsdb user show #{sunetid}`
+    rv = $?
+    self.progress("remctl return status is '#{rv}'")
+    self.progress("remctl returned '#{account}'")
+
+    # if the remctl command failed, then there is no ticket or remctl
+    # client is not installed.
+    unless rv.success?
+      msg = 'Please make sure remctl is installed, you have a valid kerberos ticket,
+            and you have the rights to execute remctl lsdb queries.'
+      exit_with_error(msg)
+    end
+
+    # if sunetid is not found
+    if (account.include?('not found in database')) then
+      exit_with_error("#{sunetid} is not a valid SUnet ID.")
+    end
+
+    return account
+  end
+
+  def add_user(sunetid)
+    # check if the user already exists in the data source
+    if (self.user_already_known?(sunetid)) then
+      exit_with_error("The user #{sunetid} is already known.")
+    end
+
+    # lookup a user's information in lsdb via a remctl call.
+    account = self.get_user_info_from_lsdb(sunetid)
+
+    # create a hash with all user attributes
+    u = Hash.new
+    account.each_line do |line|
+      attrib, val = line.strip.split(/:\s*/, 2)
+      next if val.nil? or val.empty?
+      u[attrib] = val unless u.key?(attrib)
+    end
+    self.progress("u is #{u}")
+
+    # add user attributes from passwd string
+    u['pwd'], u['uid'], u['gid'], u['gecos'], u['home'], u['shell'] = u[sunetid].split(/:/)
+
+    # if there is no attribute 'Services' or there is no 'afs' value there
+    # then the user has a base sponsored sunet id or inactive
+    unless u.key?('Services') and u['Services'].include?('afs')
+      u['home'] = "/home/#{sunetid}"
+    end
+
+    # add user to hiera data source
+    @hiera_data[@classname][sunetid] = {
+      'uid'     => u['uid'].to_i(),
+      'gid'     => u['gid'].to_i(),
+      'comment' => u['gecos'],
+      'home'    => u['home'],
+      'shell'   => u['shell']
+    }
+
+    self.save('add', sunetid)
+  end
+
+  def delete_user(sunetid)
+  # check if the user already exists in the data source
+    if @hiera_data[@classname].key?(sunetid)
+      just_deleted = @hiera_data[@classname].delete(sunetid)
+      self.progress('Deleted the following data from hiera:')
+      self.progress(just_deleted.to_yaml)
+    else
+      exit_without_error("The user #{sunetid} is not in the hiera data. Nothing to do.")
+    end
+
+    self.save('delete', sunetid)
+  end
+
+  def refresh_data()
+    # Iterate over each user.
+    sunetids = []
+    @hiera_data[@classname].each do |sunetid_data|
+      sunetid = sunetid_data[0]
+      sunetids.push(sunetid)
+    end
+
+    sunetids.each do |sunetid|
+      self.progress("refreshing #{sunetid}")
+      self.delete_user(sunetid)
+      self.add_user(sunetid)
+    end
+  end
+
+  def list_data()
+    # Iterate over each user.
+    sunetids = []
+    @hiera_data[@classname].each do |sunetid_data|
+      sunetid = sunetid_data[0]
+      data    = sunetid_data[1]
+      uid     = data['uid']
+      print <<~HEREDOC
+      #{sunetid}:
+        uid:     #{data['uid']}
+        gid:     #{data['gid']}
+        comment: #{data['comment']}
+        home:    #{data['home']}
+        shell:   #{data['shell']}
+      HEREDOC
+    end
+  end
+
+  def make_git_commit(action, sunetid)
+    if (@git_commit) then
+      if (@dryrun) then
+        self.dryrun_progress("committing to git")
+      else
+        # commit the change to git, watch for errors
+        # construct git commit message.
+        if (action == 'add') then
+          msg = "added user #{sunetid} to"
+        elsif (action == 'delete') then
+          msg = "deleted user #{sunetid} from"
+        elsif (action == 'refresh') then
+          msg = "refreshed all users in"
+        else
+          exit_with_error("cannot do a git commit with action '#{action}'")
+        end
+        commit_msg = "#{msg} users module data source (#{@classname})."
+
+        if system('git add ' + @data_source.to_s)
+          unless system(%Q[git commit -m "#{commit_msg}"])
+            exit_with_error('Error committing the change to git. Please do it manually.')
+          end
+        else
+          exit_with_error('Error adding the data source file to git. Please add and commit manually.')
+        end
+
+        puts <<-EOS
+
+    User "#{sunetid}" has been successfully added. The commit has not yet been
+    pushed to the remote. To complete this change, run:
+
+    	git push
+  EOS
+      end
+    else
+      # Skipping git commit
+      self.progress("skipping git commit")
+    end
+
+    return
+  end
+
+  def write_data_source_file()
+    if (@dryrun) then
+      dryrun_progress("writing changes to '#{@data_source}'")
+    else
+      File.open(@data_source, 'w') {|f| f.write @hiera_data.to_yaml }
+    end
+  end
+
+  def save(action, sunetid)
+    self.write_data_source_file()
+    self.make_git_commit(action, sunetid)
+  end
+
+end
+
+########################################################################
+########################################################################
+
+## # #### # #### # #### # #### # #### # #### # #### # #### # #### # ####
+### Argument and option parsing
+
+ARGV << '-h' if ARGV.empty?
+
+options = {}
+optparse = OptionParser.new do |opts|
+  opts.banner = "Usage: manage-user (add|delete) <sunet_id> [-v] [-c classname]\n" \
+                '       manage-user (list|refresh)          [-v] [-c classname]'
+  opts.on('-v', '--[no-]verbose', 'Run verbosely') do |v|
+    options[:verbose] = v
+  end
+
+  opts.on('-n', '--[no-]dry-run', 'Do a dry-run') do |n|
+    options[:dryrun] = n
+  end
+
+  opts.on('-g', '--[no-]git-commit', 'Commit the changes to git (default: no commit)') do |g|
+    options[:gitcommit] = g
+  end
+
+  class_default = 'users'
+  opts.on('-c', '--classname CLASSNAME',
+    "-c Puppet class where users are defined (defaults to '#{class_default}')") do |lib|
+    options[:classname] = lib
+  end
+end.parse!
+
+optparse.parse!
+
+muser = ManageUser.new()
+
+if (options.key?(:verbose)) then
+  muser.verbose = options[:verbose]
+end
+
+if (options.key?(:dryrun)) then
+  muser.dryrun = options[:dryrun]
+end
+
+if (options.key?(:gitcommit)) then
+  muser.git_commit = options[:gitcommit]
+end
+
+if (options.key?(:classname)) then
+  classname = options[:classname]
+  muser.classname = classname
+  muser.progress("overriding classname with #{classname}")
+end
+
+
+action    = ARGV.shift
+sunetid   = ARGV.shift
+muser.progress("action:    #{action}")
+muser.progress("sunetid:   #{sunetid}")
+muser.progress("classname: #{classname}")
+muser.progress("dryrun:    #{muser.dryrun}")
+muser.progress("gitcommit: #{muser.git_commit}")
+
+if ((action =~ /^(add|delete)$/) && (! sunetid)) then
+  exit_with_error("You must provide a sunetid with the #{action} action.")
+end
+
+if (action !~ /^(add|delete|refresh|list)$/) then
+  exit_with_error("The action must be one of 'add', 'delete', 'list', or 'refresh'")
+end
+
+## # #### # #### # #### # #### # #### # #### # #### # #### # #### # ####
+### Read in the existing users from the YAML file.
+
+# Set the data source
+muser.set_data_source()
+muser.progress(muser.hiera_data)
+
+## # #### # #### # #### # #### # #### # #### # #### # #### # #### # ####
+### Take action.
+if (action == 'add') then
+  muser.add_user(sunetid)
+elsif (action == 'delete') then
+  muser.delete_user(sunetid)
+elsif (action == 'refresh') then
+  muser.refresh_data()
+elsif (action == 'list') then
+  muser.list_data()
+end
diff --git a/refresh_users b/refresh_users
new file mode 100755
index 0000000..0ad1afd
--- /dev/null
+++ b/refresh_users
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+./manage-user refresh $@
-- 
GitLab