#!/usr/bin/ruby ### Copyright 2014 Pixar ### ### Licensed under the Apache License, Version 2.0 (the "Apache License") ### with the following modification; you may not use this file except in ### compliance with the Apache License and the following modification to it: ### Section 6. Trademarks. is deleted and replaced with: ### ### 6. Trademarks. This License does not grant permission to use the trade ### names, trademarks, service marks, or product names of the Licensor ### and its affiliates, except as required to comply with Section 4(c) of ### the License and to reproduce the content of the NOTICE file. ### ### You may obtain a copy of the Apache License at ### ### http://www.apache.org/licenses/LICENSE-2.0 ### ### Unless required by applicable law or agreed to in writing, software ### distributed under the Apache License with the above modification is ### distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY ### KIND, either express or implied. See the Apache License for the specific ### language governing permissions and limitations under the Apache License. ############################## # == Synopsis # Add, remove, or change the Network Segments in the JSS based on data from an input file # in CSV, tab, or other delimited format. # # == Usage # subnet-update [-t | -d delimiter] [-h] file # # # == Author # Chris Lasell <chrisl@pixar.com> # # == Copyright # Copyright (c) 2014 Pixar Animation Studios ############################## # Libraries require 'jss-api' require 'getoptlong' ############################## # The app object class App ############################## # Constants USAGE = "Usage: #{File.basename($0)} [-d delim] [--header] [-c col1,col2,col3 ] [-m manual-prefix] [--help] /path/to/file" POTENTIAL_COLUMNS = [:name, :starting, :ending, :cidr] # Whenever we process a file, we store it here. The next time we # run, if the input file is identical to this, we exit witout doing anything. DEFAULT_CACHE_FILE = Pathname.new("~/.last_subnet_update").expand_path DEFAULT_DELIMITER = "\t" DEFAULT_COLUMNS = [:name, :starting, :ending] DEFAULT_MANUAL_PREFIX = "Manual-" attr_reader :debug ############### # set up def initialize(args) # set defaults @debug = false @delim = DEFAULT_DELIMITER @header = false @columns = DEFAULT_COLUMNS @cache_file = DEFAULT_CACHE_FILE @manual_prefix = DEFAULT_MANUAL_PREFIX #define the cli opts cli_opts = GetoptLong.new( [ '--help', '-H', GetoptLong::NO_ARGUMENT ], [ '--delimiter', '--delim', '-d', GetoptLong::REQUIRED_ARGUMENT], [ '--header', '-h', GetoptLong::NO_ARGUMENT], [ '--columns', '-c', GetoptLong::OPTIONAL_ARGUMENT], [ '--manual-prefix', '-m', GetoptLong::OPTIONAL_ARGUMENT], [ '--cache', GetoptLong::REQUIRED_ARGUMENT ], [ '--debug', GetoptLong::NO_ARGUMENT], [ '--server', '-S', GetoptLong::OPTIONAL_ARGUMENT], [ '--port', '-P', GetoptLong::OPTIONAL_ARGUMENT], [ '--user', '-U', GetoptLong::OPTIONAL_ARGUMENT], [ '--no-verify-cert', '-V', GetoptLong::NO_ARGUMENT], [ '--timeout', '-T', GetoptLong::OPTIONAL_ARGUMENT] ) # parse the cli opts cli_opts.each do |opt, arg| case opt when '--help' then show_help when '--delimiter' then @delim = arg when '--header' then @header = true when '--columns' then @columns = arg.split(',').map{|c| c.to_sym} when '--manual-prefix' then @manual_prefix = arg when '--cache' then @cache_file = Pathname.new arg when '--debug' then @debug = true when '--server' @server = arg when '--port' @port = arg when '--user' @user = arg when '--no-verify-cert' @verify_cert = false when '--timeout' @timeout = arg end # case end # each opt arg @columns = nil if @columns and @columns.empty? @file = args.shift end # init ############### # Go! def run unless @file puts "No input file specified." puts USAGE return end @file = Pathname.new @file unless parse_file puts "File hasn't changed since last time, no changes to make!" return end # use any config settings defined.... @user ||= JSS::CONFIG.api_username @server ||= JSS::CONFIG.api_server_name @getpass = $stdin.tty? ? :prompt : :stdin raise JSS::MissingDataError, "No JSS Username provided or found in the JSS gem config." unless @user raise JSS::MissingDataError, "No JSS Server provided or found in the JSS gem config." unless @server JSS::API.connect( :server => @server, :port => @port, :verify_cert => @verify_cert, :user => @user, :pw => @getpass, :stdin_line => 1, :timeout => @timeout ) update_network_segments end # run ##################################### ### ### Show Help ### def show_help puts <<-FULLHELP Update the JSS Network Segments from a delimited file of subnet information. #{USAGE} Options: -d, --delimiter - The field delimiter in the file, defaults to tab. -c, --columns [col1,col2,col3] - The column order in file, must include 'name', 'starting', and either 'ending' or 'cidr' -h, --header - The first line of the file is a header line, possibly defining the columns -m, --manual-prefix - Network Segment names in the JSS with this prefix are ignored. Defaults to 'Manual-' --cache /path/.. - Where read/save the input data for comparison between runs. Defaults to ~/.last_subnet_update -S, --server srvr - specify the JSS API server name -P, --port portnum - specify the JSS API port -U, --user username - specify the JSS API user -V, --no-verify-cert - Allow self-signed, unverified SSL certificate -T, --timeout secs - specify the JSS API timeout -H, --help - show this help --debug - show the ruby backtrace when errors occur This program parses the input file line by line (possibly accounting for a header line). Each line defines the name and IP-range of a subnet/network segment. - If a segment doesn't exist in the JSS, it is created. - If a segment's range has changed, it is updated in the JSS. - If a JSS segment doesn't exist in the file, it is deleted from the JSS unless its name starts with the --manual-prefix Input File: - The file must contain three columns, separated by the --delimiter, with these names, in any order: - 'name' (the network segment name) - 'starting' (the starting IP address of the network segment) - EITHER of: - 'ending' (the ending IP address of the network segment) - 'cidr' (the network range of the segment as a CIDR bitmask, e.g. '24') Notes: - The --columns option is a comma-separted list of the three column names aboveindicating the column-order in the file. - If --columns are not provided, and --header is specified, the first line is assumed to contain the column names, separated by the delimiter - If --header is provided with --columns, the first line of the file is ignored. - The raw data from the file is cached and compared to the input file at the next run. If the data is identical, no JSS connection is made. - If no API settings are provided, they will be read from /etc/jss_gem.conf and ~/.jss_gem.conf. See the JSS Gem docs for details. - The password for the connection will be read from STDIN or prompted if needed FULLHELP exit 0 end ######################## # parse the incoming data file. # If the file hasn't changed from the last time we processed it # then return false # otherwise parse it into @parsed_data and return true # @parsed_data is an array of hashes, each with :name, :starting, and :ending # def parse_file raise "'#{@file}' is not readable, or not a regular file" unless @file.readable? and @file.file? # read in the file @raw_data = @file.read # compare it to the one we used last time if @cache_file.readable? return false if @raw_data == @cache_file.read end # split the data into an array by newlines lines = @raw_data.split "\n" # remove the first line if its a header, and parse it into the columns # if needed if @header header = lines.shift @columns ||= header.split(/\s*#{@delim}\s*/).map{|c| c.to_sym} end # check some state raise "Columns must include 'name' and 'starting'" unless @columns.include?(:name) and @columns.include?(:starting) raise "Columns must include either 'ending' or 'cidr'" unless @columns.include?(:ending) or @columns.include?(:cidr) @use_cidr = @columns.include? :cidr # which columns are which in the file? name = @columns.index :name starting = @columns.index :starting ending = @use_cidr ? @columns.index(:cidr) : @columns.index(:ending) # split each line and convert it into a hash @parsed_data = lines.map do |line| parts = line.split(@delim).map{|f| f.strip } unless parts[name] and parts[starting] and parts[ending] puts "Skipping invalid line: #{line}" next end {:name => parts[name], :starting => parts[starting], :ending => parts[ending]} end # parsed data is now an array of hashes return true end ############################################# ############################################# # Update the JSS Network Segments from GIT_NETBLOCKS_URL, q.v. def update_network_segments # CREATE any that are in the parsed data but not yet in the JSS, # and UPDATE any that exist but have modified ranges. # While looping through, make a hash of JSS::NetworkSegment objects, keyed by their name. segs_from_data = {} @parsed_data.each do |pd| # skip anthing with the manual prefix next if pd[:name].start_with? @manual_prefix ender = @use_cidr ? :cidr : :ending_address begin this_seg = JSS::NetworkSegment.new(:id => :new, :name => pd[:name], :starting_address => pd[:starting], ender => pd[:ending]) # If the new netsegment should have other settings (dist. point, netboot server, etc...) # here's where you should apply those settings. this_seg.create puts "Added Network Segment '#{this_seg.name}' to the JSS" # it already exists, so see if it needs any changes rescue JSS::AlreadyExistsError # there's already one with this name, so just grab it. this_seg = JSS::NetworkSegment.new( :name => pd[:name]) # does the startng addres need to be changed? needs_update = this_seg.starting_address.to_s != pd[:starting].to_s # even if we don't need to update the starting, we might need to update # the ending... unless needs_update if @use_cidr needs_update = this_seg.cidr.to_i != pd[:ending].to_i else needs_update = this_seg.ending_address.to_s != pd[:ending].to_s end # if @use_cidr end #unless needs update # did we decide we need an update? if needs_update this_seg.starting_address = pd[:starting] if @use_cidr this_seg.cidr = pd[:ending].to_i else this_seg.ending_address = pd[:ending] end # if @use_cidr this_seg.update puts "Updated IP range for Network Segment '#{this_seg.name}'" else # doesn't need update puts "Network Segment '#{this_seg.name}' doesn't have any changes." end # if needs update # rescue other errors rescue raise "There was an error with NetworkSegment #{pd[:name]}: #{$!}" end # begin segs_from_data[this_seg.name] = this_seg end # DELETE those in jss, but not in parsed data, # unless the name starts with @manual_prefix JSS::NetworkSegment.map_all_ids_to(:name).each do |id,name| next if name.start_with? @manual_prefix unless segs_from_data.keys.include? name JSS::NetworkSegment.new(:id => id).delete puts "Deleted Network Segment '#{name}' from the JSS" end # unless end # jss_uids.each seg # save the data into a file for comparison next time @cache_file.jss_save @raw_data # all done return true end # update_network_segments end # app ############################## # create the app and go begin app = App.new(ARGV) app.run rescue # handle exceptions not handled elsewhere puts "An error occurred: #{$!}" puts "Backtrace:" if app.debug puts $@ if app.debug ensure end