Skip to content
Snippets Groups Projects
Verified Commit a4dd763b authored by Alexander Olofsson's avatar Alexander Olofsson
Browse files

Initial commit

parents
Branches
Tags
No related merge requests found
Pipeline #99243 failed
Showing
with 874 additions and 0 deletions
/.bundle/
/.yardoc
/_yardoc/
/coverage/
/doc/
/pkg/
/spec/reports/
/tmp/
/vendor/
/Gemfile.lock
---
image: "ruby:2.7"
# Cache gems in between builds
cache:
paths:
- vendor/ruby
before_script:
- gem install bundler -N
- bundle install -j $(nproc) --path vendor
rubocop:
script:
- bundle exec rubocop lib/ -f p -f ju -o junit.xml
artifacts:
reports:
junit: junit.xml
pages:
before_script: []
script:
- gem install yard redcarpet
- yard doc -o public/
artifacts:
paths:
- public/
only:
- master
rake:
script:
- bundle exec rake test
---
AllCops:
Exclude:
- "test/**/*"
- "vendor/**/*"
NewCops: enable
TargetRubyVersion: 2.6
SuggestExtensions: false
Style/ClassAndModuleChildren:
Enabled: false
Style/StringLiterals:
Enabled: true
EnforcedStyle: double_quotes
Style/StringLiteralsInInterpolation:
Enabled: true
EnforcedStyle: double_quotes
Layout/LineLength:
Max: 120
Metrics/ClassLength:
Max: 200
Metrics/MethodLength:
Max: 32
Metrics/AbcSize:
Enabled: false
Metrics/CyclomaticComplexity:
Enabled: false
Metrics/PerceivedComplexity:
Enabled: false
## **Unreleased**
- Initial release
Gemfile 0 → 100644
# frozen_string_literal: true
source "https://rubygems.org"
# Specify your gem's dependencies in liudesk_cmdb.gemspec
gemspec
gem "minitest", "~> 5.0"
gem "rake", "~> 13.0"
gem "rubocop", "~> 1.21"
The MIT License (MIT)
Copyright (c) 2023 Alexander Olofsson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
LiUdesk CMDB
===========
A Ruby gem to handle communicating with the Linköping University TopDesk-based CMDB
## Usage example
```ruby
require 'liudesk_cmdb'
client = LiudeskCMDB::Client.new 'https://api.test.it.liu.se', subscription_key: 'XXXXXXXXXXXXXXXX'
machine = LiudeskCMDB::Models::LinuxClientV1.get client, 'sanghelios.it.liu.se'
#<LiudeskCMDB::Models::LinuxClientV1:0x0000563973e1ffc0
# @client=#<LiudeskCMDB::Client:0x0000563973c86510 ...
# @data=
# {...
# :hostname=>"sanghelios.it.liu.se",
# :operating_system_install_date=>nil,
# ...}>
machine.hardware
#<LiudeskCMDB::Models::HardwareV1:0x0000563973cb4500
# @client=#<LiudeskCMDB::Client:0x0000563973c86510 ...
# @data=
# {...
# :hostname=>"sanghelios.it.liu.se",
# :name=>"HP - EliteDesk 800 G2 SFF - 00108",
# ...}>
machine.operating_system_install_date = Time.now
machine.update
#<LiudeskCMDB::Models::LinuxClientV1:0x0000563973e1ffc0
# @client=#<LiudeskCMDB::Client:0x0000563973c86510 ...
# @data=
# {..
# :hostname=>"sanghelios.it.liu.se",
# :operating_system_install_date=>2023-06-13 12:46:14.549 UTC,
# ...}>
```
## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/ananace/ruby-liudesk-cmdb
The project lives at https://gitlab.liu.se/ITI/ruby-liudesk-cmdb
## License
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
Rakefile 0 → 100644
# frozen_string_literal: true
require "bundler/gem_tasks"
require "rake/testtask"
Rake::TestTask.new(:test) do |t|
t.libs << "test"
t.libs << "lib"
t.test_files = FileList["test/**/test_*.rb"]
end
require "rubocop/rake_task"
RuboCop::RakeTask.new
task default: %i[test rubocop]
# frozen_string_literal: true
require "json"
require "logging"
require "net/http"
require "time"
require_relative "liudesk_cmdb/version"
require_relative "liudesk_cmdb/client"
require_relative "liudesk_cmdb/model"
require_relative "liudesk_cmdb/util"
# Base module for Liudesk CMDB
module LiudeskCMDB
def self.debug!
logger.level = :debug
end
def self.logger
@logger ||= ::Logging.logger[self].tap do |logger|
logger.add_appenders ::Logging.appenders.stdout
logger.level = :info
end
end
class Error < StandardError; end
# An error that occurred when handling a request
class RequestError < Error
attr_reader :code, :errors
def initialize(body, code)
@code = code
@errors = body["errors"] || body[:errors]
super message
end
def message
if @errors.is_a? Hash
@errors.values.flatten.join ", "
else
@errors.map { |err| err["message"] || err[:message] }.join ", "
end
end
end
# Data models
module Models
def self.const_missing(const)
@looked_for ||= {}
model = const.to_s.snake_case.sub(/_v\d+$/, "")
raise "Class not found: #{const}" if @looked_for[model]
@looked_for[model] = true
require_relative "liudesk_cmdb/models/#{model}"
klass = const_get(const)
return klass if klass
raise "Class not found: #{const}"
end
end
end
# frozen_string_literal: true
module LiudeskCMDB
# CMDB Client
#
class Client
attr_accessor :server, :subscription_key
attr_writer :user_agent
def initialize(server, subscription_key:)
@server = server
@subscription_key = subscription_key
@user_agent = nil
end
def user_agent
@user_agent || "Ruby/LiudeskCMDB v#{LiudeskCMDB::VERSION}"
end
def logger
Logging.logger[self]
end
def get(path, version, query: nil)
request :get, path, version: version, query: query
end
def delete(path, version)
request :delete, path, version: version
end
def post(path, version, body)
request :post, path, version: version, body: body
end
def patch(path, version, body)
request :patch, path, version: version, body: body
end
def request(method, path, version:, body: nil, query: nil)
uri = URI([server, path].join("/"))
if query
query = URI.encode_www_form(query) if query.is_a? Hash
if uri.query&.length&.positive?
uri.query += "&#{query}"
else
uri.query = query
end
end
request = Net::HTTP.const_get(method.to_s.capitalize).new(uri.request_uri)
request["User-Agent"] = user_agent
request["Accept"] = "application/json"
request["LiU-Api-Version"] = version
request["Ocp-Apim-Subscription-Key"] = subscription_key
if body
body = body.to_json if body.is_a? Hash
raise ArgumentError, "Body must be a string or hash, not a #{body.class}" unless body.is_a?(String)
request["Content-Type"] = "application/json"
request.body = body
end
print_req_res request if logger.debug?
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
http.request(request)
end
print_req_res response if logger.debug?
response.value
response.body
rescue Net::HTTPClientException
body = JSON.parse(response.body)
raise LiudeskCMDB::RequestError, body, response.code
end
def pretty_print_instance_variables
%i[@server]
end
def pretty_print(pp)
pp.pp_object(self)
end
alias inspect pretty_print_inspect
private
def print_req_res(http, body: true)
if http.is_a? Net::HTTPRequest
logger.debug "> #{http.method} #{http.path}"
http.each_header do |k, v|
vv = "[redacted]" if k.to_s.downcase == "ocp-apim-subscription-key"
vv ||= v
logger.debug " #{k}: #{vv}"
end
else
logger.debug "< #{http.code} #{http.message}"
http.each_header { |k, v| logger.debug " #{k}: #{v}" }
end
logger.debug http.body if body && http.body&.size&.positive?
end
end
end
# frozen_string_literal: true
module LiudeskCMDB
# CMDB Model abstraction
#
# A helper class that implements a DSL for describing the CMDB models
class Model
attr_accessor :client
def initialize(client, **fields)
@client = client
@data = {}
self.class.fields.each do |field, _|
@data[field] = fields[field] if fields.key? field
end
@old_data = @data.dup
@unknown = nil
end
def logger
Logging.logger[self]
end
def identifier
@data[self.class.identifier]
end
def identifier=(identifier)
@data[self.class.identifier] = identifier
end
def stored?
!identifier.nil?
end
def unknown_fields
@unknown
end
def unknown_fields?
@unknown&.any? || false
end
def api_url
[self.class.api_url, identifier].compact.join("/")
end
class << self
attr_reader :fields
def create(client, **data)
new(client, **data).create
end
def get(client, identifier)
obj = new(client)
obj.identifier = identifier
obj.refresh
end
def list(client, **params)
params = params.transform_keys { |k| k.to_s.camel_case(capitalized: true) }
data = JSON.parse(client.get(api_url, model_version, query: params))
data.map { |obj| new(client).send(:load_data, obj) }
end
def search(client, query)
raise LiudeskCMDB::Error, "Model #{self} does not support search" unless @supports_search
query = query.map { |k, v| "#{k}==#{v}" }.join(";") if query.is_a? Hash
data = JSON.parse(client.get([api_url, "search"].join("/"), model_version, query: { query: query }))
data.map { |obj| new(client).send(:load_data, obj) }
end
def api_url
["liudesk-cmdb", "api", @api_name, @model_name].compact.join("/")
end
def api_name(name = nil)
@api_name = name if name
@api_name
end
def model_name(name = nil)
@model_name = name if name
@model_name
end
def model_version(version = nil)
@model_version = version if version
@model_version
end
def identifier(field = nil)
@identifier = field if field
@identifier
end
protected
def read_fields(*fields)
register_fields(fields, access: :read)
end
def write_fields(*fields)
register_fields(fields, access: :write)
end
def access_fields(*fields)
register_fields(fields, access: :read_write)
end
def field_attributes(field, **options)
raise ArgumentError, "No such field #{field.inspect} in model #{self.class}" unless @fields.key? field
@fields[field].merge! options
end
def supports_search
@supports_search = true
end
private
def register_fields(fields, **options)
@fields ||= {}
fields.each do |f|
@fields[f] = {
name: f.to_s.camel_case
}.merge(options).merge(field: f)
access = options[:access]
define_method(f.to_s) { instance_variable_get(:@data)[f] } if access.to_s.start_with?("read")
next unless access.to_s.end_with?("write")
define_method("#{f}=") { |val| instance_variable_get(:@data)[f] = val }
end
end
end
def create
data = client.post(self.class.api_url, self.class.model_version, data_for_write(all: true))
load_data(JSON.parse(data))
@old_data = @data.dup
self
end
def refresh
data = client.get(api_url, self.class.model_version)
load_data(JSON.parse(data))
@old_data = @data.dup
self
end
def reset!
@data = @old_data.dup
self
end
def destroy
client.delete(api_url, self.class.model_version)
nil
end
def update
data = client.patch(api_url, self.class.model_version, data_for_write.transform_values { |v| v.nil? ? "" : v })
load_data(JSON.parse(data), merge: true)
@old_data = @data.dup
self
end
def pretty_print_instance_variables
%i[@client @data @unknown_fields]
end
def pretty_print(pp)
pp.pp_object(self)
end
alias inspect pretty_print_inspect
protected
def dirty_data
@data.reject { |k, v| @old_data[k] == v }
end
def data_for_write(all: false)
data = @data if all
data ||= dirty_data
data = data.dup
write_fields = self.class.fields.select { |_, field| field[:access].to_s.end_with?("write") }.map { |k, _| k }
self.class.fields.each do |key, attributes|
next unless data.key? key
case attributes[:convert].to_s
when Time.to_s
data[key] = data[key].strftime("%FT%T.%LZ") if data[key].is_a? Time
when Symbol.to_s
data[key] = data[key].to_s if data[key].is_a? Symbol
end
end
data.select { |k, _| write_fields.include?(k) }.transform_keys { |k| self.class.fields.dig(k, :name) }
end
def load_data(data, merge: false)
fields = self.class.fields.to_h { |_, v| [v[:name], v] }
missing = data.reject { |k| fields.key? k }
converted = data.select { |k| fields.key? k }.transform_keys { |k| fields.dig(k, :field) }
logger.warn "Received data for unkown fields #{missing.keys.join ", "}" if missing.any?
@unknown = missing
@data = {} unless merge
@data.merge!(converted)
self.class.fields.each do |key, attributes|
next unless @data[key]
case attributes[:convert].to_s
when Time.to_s
@data[key] = Time.parse(@data[key])
when Symbol.to_s
@data[key] = @data[key].to_sym
end
end
self
end
end
end
# frozen_string_literal: true
module LiudeskCMDB::Models
# Computer Hardware v1
class HardwareV1 < LiudeskCMDB::Model
model_name "Hardware"
model_version :v1
identifier :guid
supports_search
read_fields :guid, :created_date, :updated_date, :name
access_fields \
:division, :asset_owner, :status, :stolen,
:capital_equipment_id, :capital_object_id, :supplier, :supplier_article_number,
:purchase_date, :purchase_order, :purchase_order_reference, :purchase_price,
:delivery_date, :supplier_asset_id, :warranty_enddate, :service_agreement_enddate,
:mac, :network_access_role, :make, :model, :serial_number, :description, :hostname
field_attributes :hostname, name: "hostName"
field_attributes :created_date, convert: Time
field_attributes :updated_date, convert: Time
field_attributes :purchase_date, convert: Time
field_attributes :delivery_date, convert: Time
field_attributes :warranty_enddate, convert: Time
field_attributes :service_agreement_enddate, convert: Time
def to_s
name
end
end
end
# frozen_string_literal: true
module LiudeskCMDB::Models
# Linux Client v1
class LinuxClientV1 < LiudeskCMDB::Model
api_name "Clients"
model_name "linux"
model_version :v1
identifier :hostname
read_fields :created_date, :updated_date
access_fields \
:hostname, :division, :asset_owner, :certificate_information,
:network_access_role, :hardware_id,
:operating_system_type, :operating_system, :operating_system_install_date,
:ad_creation_date, :active_directory_ou, :client_classification
field_attributes :hostname, name: "hostName"
field_attributes :hardware_id, name: "hardwareID"
field_attributes :active_directory_ou, name: "activeDirectoryOU"
field_attributes :created_date, convert: Time
field_attributes :updated_date, convert: Time
field_attributes :operating_system_install_date, convert: Time
field_attributes :ad_creation_date, convert: Time
def to_s
hostname
end
def hardware
@hardware ||= LiudeskCMDB::Models::HardwareV1.get(client, hardware_id)
end
def hardware=(hardware)
raise ArgumentError, "Must be a HardwareV1" unless hardware.is_a? LinudeskCMDB::Models::HardwareV1
@hardware = hardware
self.hardware_id = hardware.guid if hardware
end
end
end
# frozen_string_literal: true
module LiudeskCMDB::Models
# Server OS v1
class ServerV1 < LiudeskCMDB::Model
model_name "Server"
model_version :v1
identifier :hostname
read_fields :created_date, :updated_date
access_fields \
:hostname, :division, :asset_owner, :certificate_information,
:network_access_role, :hardware_id,
:operating_system_type, :operating_system, :operating_system_install_date,
:ad_creation_date, :active_directory_ou, :group_or_lab,
:contact_information, :misc_information, :management_system, :management_system_id, :icinga_link, :foreman_link
field_attributes :hostname, name: "hostName"
field_attributes :hardware_id, name: "hardwareID"
field_attributes :active_directory_ou, name: "activeDirectoryOU"
field_attributes :created_date, convert: Time
field_attributes :updated_date, convert: Time
field_attributes :operating_system_install_date, convert: Time
field_attributes :ad_creation_date, convert: Time
def to_s
hostname
end
def hardware
@hardware ||= LiudeskCMDB::Models::HardwareV1.get(client, hardware_id)
end
def hardware=(hardware)
raise ArgumentError, "Must be a HardwareV1" unless hardware.is_a? LinudeskCMDB::Models::HardwareV1
@hardware = hardware
self.hardware_id = hardware.guid if hardware
end
end
end
# frozen_string_literal: true
# String extensions for camel/snake case conversion
class String
def snake_case
gsub(/::/, "/")
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
.tr("-", "_")
.downcase
end
def camel_case(capitalized: false)
first, *rest = split("_")
first = first.capitalize if capitalized
[first, rest.map(&:capitalize)].join
end
end
# frozen_string_literal: true
module LiudeskCMDB
VERSION = "0.1.0"
end
# frozen_string_literal: true
require_relative "lib/liudesk_cmdb/version"
Gem::Specification.new do |spec|
spec.name = "liudesk_cmdb"
spec.version = LiudeskCMDB::VERSION
spec.authors = ["Alexander Olofsson"]
spec.email = ["alexander.olofsson@liu.se"]
spec.summary = "Ruby gem for communicating with the LiU CMDB"
spec.description = spec.summary
spec.homepage = "https://gitlab.liu.se/ITI/ruby-liudesk-cmdb"
spec.license = "MIT"
spec.required_ruby_version = ">= 2.6.0"
spec.metadata["allowed_push_host"] = ""
spec.metadata["rubygems_mfa_required"] = "true"
spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = spec.homepage
spec.metadata["changelog_uri"] = [spec.homepage, "-", "blob", "master", "CHANGELOG.md"].join "/"
spec.files = Dir["{lib,sig}/**/*.rb"] + %w[Gemfile Rakefile README.md CHANGELOG.md LICENSE.txt liudesk_cmb.gemspec]
spec.require_paths = ["lib"]
spec.add_dependency "logging"
end
module LiudeskCMDB
VERSION: String
class Client
attr_accessor server: String | URI
attr_accessor subscription_key: String
attr_accessor user_agent: String
def initialize: (server: String | URI, subscription_key: String) -> void
def get: (path: String, version: Symbol, query: Hash? | String?) -> String
def delete: (path: String, version: Symbol) -> String
def post: (path: String, version: Symbol, body: Hash? | String?) -> String
def patch: (path: String, version: Symbol, body: Hash? | String?) -> String
def request: (method: Symbol, path: String, version: Symbol, body: Hash | String, query: Hash? | String?) -> String
end
class Model
attr_accessor client: Client
attr_accessor identifier: String
attr_reader stored?: bool
attr_reader unknown_fields: Hash?
attr_reader unknown_fields?: bool
attr_reader api_url: String
def initialize: (client: Client, fields: Hash) -> void
def create: () -> void
def refresh: () -> void
def reset!: () -> void
def destroy: () -> void
def update: () -> void
end
module Models
class HardwareV1
attr_reader guid: String
attr_reader created_date: Time
attr_reader updated_date: Time
attr_reader name: String
attr_accessor division: String?
attr_accessor asset_owner: String?
attr_accessor status: String
attr_accessor stolen: bool
attr_accessor capital_equipment_id: String?
attr_accessor capital_object_id: String?
attr_accessor supplier: String?
attr_accessor supplier_article_number: String?
attr_accessor purchase_date: Time?
attr_accessor purchase_order: String?
attr_accessor purchase_order_reference: String?
attr_accessor purchase_price: Float?
attr_accessor delivery_date: Time?
attr_accessor supplier_asset_id: String?
attr_accessor warranty_enddate: Time?
attr_accessor service_agreement_enddate: Time?
attr_accessor mac: String?
attr_accessor network_access_role: String
attr_accessor make: String
attr_accessor model: String
attr_accessor serial_number: String?
attr_accessor description: String
attr_accessor hostname: String?
end
end
end
[
{
"division": "UF/LIUIT/ITS",
"assetOwner": "foref48",
"certificateInformation": "a909502dd82ae41433e6f83886b00d4277a32a7b",
"networkAccessRole": "None",
"hardwareID": "8e2cbcf0-1c79-4b4e-8813-71f8da3c3d81",
"operatingSystemType": "Windows 10",
"operatingSystem": "Windows 10 N Education 19042.2965",
"operatingSystemInstallDate": "2023-04-06T14:15:58.368Z",
"hostName": "win00001",
"adCreationDate": "2023-04-06T14:15:58.368Z",
"activeDirectoryOU": "eKlient/liuit",
"groupOrLab": "string",
"contactInformation": "string",
"miscInformation": "string",
"managementSystem": "string",
"managementSystemId": "string",
"icingaLink": "string",
"foremanLink": "string",
"createdDate": "2023-04-06T14:15:58.368Z",
"updatedDate": "2023-04-06T14:15:58.368Z"
}
]
{
"division": "UF/LIUIT/ITS",
"assetOwner": "foref48",
"status": "Lagerförd",
"stolen": false,
"capitalEquipmentId": "ABCD1234",
"capitalObjectId": "EFGH5678",
"supplier": "ATEA",
"supplierArticleNumber": "ART1234",
"purchaseDate": "2023-04-06T14:15:58.368Z",
"purchaseOrder": "LIU100024041",
"purchaseOrderReference": "Till foref48",
"purchasePrice": 1099.0,
"deliveryDate": "2023-04-08T14:15:58.368Z",
"supplierAssetId": "001255",
"warrantyEnddate": "2025-04-08T14:15:58.368Z",
"serviceAgreementEnddate": "2023-04-08T14:15:58.368Z",
"mac": "30:D0:42:E7:2B:DF",
"networkAccessRole": "Guest",
"make": "HP",
"model": "eliteBook G8",
"serialNumber": "dfg54fgh34efg",
"description": "En valfri beskrivning",
"guid": "8e2cbcf0-1c79-4b4e-8813-71f8da3c3d81",
"name": "HP eliteBook G8-0001",
"createdDate": "2023-04-06T14:15:58.368Z",
"updatedDate": "2023-04-06T14:15:58.368Z",
"hostName": "win00001"
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment