pal_tool 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +132 -0
  4. data/CODE_OF_CONDUCT.md +84 -0
  5. data/Dockerfile +10 -0
  6. data/Gemfile +14 -0
  7. data/Gemfile.lock +72 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +124 -0
  10. data/Rakefile +12 -0
  11. data/bin/console +15 -0
  12. data/bin/setup +8 -0
  13. data/exe/pal +47 -0
  14. data/lib/pal/common/local_file_utils.rb +37 -0
  15. data/lib/pal/common/object_helpers.rb +27 -0
  16. data/lib/pal/common/safe_hash_parse.rb +87 -0
  17. data/lib/pal/configuration.rb +77 -0
  18. data/lib/pal/handler/base.rb +138 -0
  19. data/lib/pal/handler/definitions/aws_cur.json +8 -0
  20. data/lib/pal/handler/manager.rb +30 -0
  21. data/lib/pal/handler/processor.rb +84 -0
  22. data/lib/pal/log.rb +29 -0
  23. data/lib/pal/main.rb +63 -0
  24. data/lib/pal/operation/actions.rb +106 -0
  25. data/lib/pal/operation/exporter.rb +183 -0
  26. data/lib/pal/operation/filter_evaluator.rb +249 -0
  27. data/lib/pal/operation/processor_context.rb +50 -0
  28. data/lib/pal/operation/projection.rb +302 -0
  29. data/lib/pal/plugin.rb +61 -0
  30. data/lib/pal/request/metadata.rb +19 -0
  31. data/lib/pal/request/runbook.rb +54 -0
  32. data/lib/pal/version.rb +5 -0
  33. data/lib/pal.rb +43 -0
  34. data/plugins/PLUGINS.md +1 -0
  35. data/plugins/operation/terminal_exporter_impl.rb +14 -0
  36. data/templates/DOCUMENTATION.md +46 -0
  37. data/templates/aws/data_transfer/data_transfer_breakdown.json +93 -0
  38. data/templates/aws/ec2/ec2_compute_hourly_breakdown.json +63 -0
  39. data/templates/aws/ec2/ec2_operation_breakdown.json +64 -0
  40. data/templates/aws/ec2/ec2_spend_breakdown.json +63 -0
  41. data/templates/aws/global_resource_and_usage_type_costs.json +41 -0
  42. data/templates/aws/kms/kms_usage_counts.json +52 -0
  43. data/templates/aws/kms/kms_usage_list.json +80 -0
  44. data/templates/aws/kms/list_of_kms_keys.json +57 -0
  45. data/templates/aws/reserved_instances/all_reserved_instance_expiries.json +41 -0
  46. data/templates/aws/reserved_instances/reserved_instance_opportunities.json +60 -0
  47. data/templates/aws/summary_cost_between_date_range.json +43 -0
  48. data/templates/aws/summary_daily_breakdown_costs.json +39 -0
  49. data/templates/azure/global_resource_type_summary.json +47 -0
  50. metadata +136 -0
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pal"
4
+ require "json"
5
+ require "jsonpath"
6
+
7
+ module Pal
8
+ # The most lazy way to find things in hashes and JSON.
9
+ # Provides default and optional params
10
+ # Provides safe navigation with hash key dot notation ('this.is.a.key')
11
+ class SafeHashParse
12
+ class << self
13
+ # @param [String] json_str
14
+ # @param [Object] key
15
+ # @param [Boolean] optional
16
+ # @param [Object] default
17
+ # @return [Array]
18
+ def extract_from_json(json_str, key, optional=false, default=nil)
19
+ val = JsonPath.new(key.to_s).on(json_str)
20
+ return val if val && !val.empty?
21
+ return [] unless optional
22
+
23
+ [default]
24
+ rescue JSON::ParserError, MultiJson::ParseError, ArgumentError => e
25
+ raise e unless optional
26
+
27
+ [default]
28
+ end
29
+
30
+ # @param [Hash] hash
31
+ # @param [Object] search_key
32
+ # @param [Boolean] optional
33
+ # @param [Object, nil] default
34
+ # @return [Object, nil]
35
+ # rubocop:disable Metrics/AbcSize
36
+ # rubocop:disable Metrics/CyclomaticComplexity
37
+ # rubocop:disable Metrics/PerceivedComplexity
38
+ def extract_from_hash(hash, search_key, optional=false, default=nil)
39
+ keys = format_key(search_key)
40
+ last_level = hash
41
+ searched = nil
42
+
43
+ keys.each_with_index do |key, index|
44
+ break unless last_level.is_a?(Hash) && last_level.key?(key.to_s)
45
+
46
+ if index + 1 == keys.length
47
+ searched = last_level[key.to_s] || last_level[key.to_sym]
48
+ else
49
+ last_level = last_level[key.to_s] || last_level[key.to_sym]
50
+ end
51
+ end
52
+
53
+ return searched if searched
54
+ return nil unless optional
55
+
56
+ default
57
+ end
58
+ # rubocop:enable Metrics/AbcSize
59
+ # rubocop:enable Metrics/CyclomaticComplexity
60
+ # rubocop:enable Metrics/PerceivedComplexity
61
+
62
+ # @param [Object] key
63
+ # @return [Array]
64
+ def format_key(key)
65
+ return [key.downcase] if key.is_a?(Symbol)
66
+
67
+ if key.is_a?(String)
68
+ return [key.downcase.to_sym] unless key.include?(".")
69
+
70
+ return key.to_s.split(".").map { |s| s.downcase.to_sym }
71
+ end
72
+
73
+ raise ArgumentError, "Key [#{key}] must be either a String or Symbol"
74
+ end
75
+
76
+ # @param [String] json
77
+ def all_values_from_json(json)
78
+ all_values(JSON.parse(json))
79
+ end
80
+
81
+ # @param [Hash] hash
82
+ def all_values(hash)
83
+ hash.flat_map { |_k, v| (v.is_a?(Hash) ? all_values(v) : [v]) }
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ require "json"
5
+ require "pal/log"
6
+
7
+ module Pal
8
+ # Configuration management module for Pal
9
+ module Configuration
10
+ include Log
11
+
12
+ # @return [Config]
13
+ def config
14
+ conf = ConfigurationSource.instance.config
15
+ raise "Set config first" unless conf
16
+
17
+ conf
18
+ end
19
+
20
+ # @param [Config] request_config
21
+ def register_config(request_config)
22
+ log_info "Setting config"
23
+ ConfigurationSource.instance.load_config(request_config)
24
+ end
25
+
26
+ # Config data class - holds configuration settings.
27
+ class Config
28
+ attr_accessor :source_file_loc, :template_file_loc, :output_dir
29
+
30
+ # @return [Boolean]
31
+ def validate
32
+ errors = decorate_errors
33
+
34
+ if errors.size.positive?
35
+ errors.each { |x| Pal.logger.info x }
36
+ raise Pal::ValidationError.new(errors, "Invalid request.")
37
+ end
38
+
39
+ true
40
+ end
41
+
42
+ # Can probably remove this
43
+ def read_template_file
44
+ JSON.parse(File.read(@template_file_loc))
45
+ end
46
+
47
+ def all_source_files
48
+ @source_file_loc.split(",")
49
+ end
50
+
51
+ private
52
+
53
+ def decorate_errors
54
+ # Add directory validation
55
+ # Check billing file is a valid billing file
56
+ errors = []
57
+ errors << "Missing property: template file [-t]." unless @template_file_loc
58
+ errors << "Missing property: input file [-s]." unless @source_file_loc
59
+ errors << "File not found: billing file must exist" unless File.exist?(@source_file_loc || "")
60
+ errors
61
+ end
62
+ end
63
+
64
+ # Config storage source for access, stored as singleton.
65
+ class ConfigurationSource
66
+ include Singleton
67
+
68
+ attr_reader :config
69
+
70
+ # @param [Config] config
71
+ # @return [Config]
72
+ def load_config(config)
73
+ @config = config
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pal"
4
+
5
+ module Pal
6
+ module Handler
7
+ class Base
8
+ include Configuration
9
+ include Log
10
+
11
+ # @param [Pal::Request::Runbook] runbook
12
+ def initialize(runbook)
13
+ @runbook = runbook
14
+ end
15
+
16
+ # @return [Operation::ProcessorContext]
17
+ # rubocop:disable Metrics/AbcSize
18
+ def process_runbook
19
+ log_debug("Processing runbook started, setting up context.")
20
+ ctx = Operation::ProcessorContext.new
21
+ ctx.column_type_definitions = retrieve_column_definitions
22
+
23
+ # Get CSV parser
24
+ # Each impl needs to return a hash of candidate columns and values
25
+ # Extract headers
26
+ # Extract values
27
+
28
+ log_debug("Calling off to parse impl for CSV processing.")
29
+
30
+ # Different impls may choose to stream file, so we hand in a location and let it decide.
31
+
32
+ config.all_source_files.each_with_index do |file, idx|
33
+ log_info "Opening file [#{file}][#{idx}]"
34
+
35
+ _parse_file(ctx, _csv_processor(file)) do |row|
36
+ ctx.add_candidate(row) if should_include?(@runbook.filters, row, ctx.column_headers)
37
+ end
38
+ end
39
+
40
+ log_info "Process completed with #{ctx.candidates.size} candidate records found."
41
+
42
+ ctx
43
+ end
44
+ # rubocop:enable Metrics/AbcSize
45
+
46
+ # @return [Boolean]
47
+ # @param [Pal::Operation::FilterEvaluator] filter_eval
48
+ # @param [Array] row
49
+ # @param [Hash] column_headers
50
+ def should_include?(filter_eval, row, column_headers)
51
+ return true unless filter_eval
52
+
53
+ filter_eval.test_property(row, column_headers)
54
+ end
55
+
56
+ # @return [Hash, nil]
57
+ def retrieve_column_definitions
58
+ overrides = @runbook.column_overrides || {}
59
+ path = File.join(File.dirname(__FILE__), "definitions/#{_type}.json")
60
+
61
+ return overrides unless File.exist?(path)
62
+
63
+ default_defs = JSON.parse(File.read(path))
64
+ default_defs.merge(overrides)
65
+ end
66
+
67
+ protected
68
+
69
+ # @abstract
70
+ # @param [ProcessorContext] _ctx
71
+ # @param [CSVProcessor] _processor
72
+ # @param [Proc] _block
73
+ # @return [Hash]
74
+ def _parse_file(_ctx, _processor, &_block)
75
+ raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
76
+ end
77
+
78
+ # @abstract
79
+ # @param [String] _source_file_loc
80
+ # @return [CSVProcessor]
81
+ def _csv_processor(_source_file_loc)
82
+ raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
83
+ end
84
+
85
+ # @abstract
86
+ # @return [String]
87
+ def _type
88
+ raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
89
+ end
90
+
91
+ def _extract_headers; end
92
+ end
93
+
94
+ # Generic has first row column headers, then data rows.
95
+ class GenericCSVHandlerImpl < Base
96
+ include Log
97
+
98
+ # @param [ProcessorContext] ctx
99
+ # @param [Pal::Operation::CSVProcessor] csv_processor
100
+ # @param [Proc] _block
101
+ # @return [Hash]
102
+ # ---
103
+ # Each impl needs to return a hash of candidate columns and values
104
+ # eg. { col_name: col_value, col_name_2: col_value_2 }
105
+ def _parse_file(ctx, csv_processor, &_block)
106
+ log_info("Starting to process file, using #{csv_processor.class} processor for #{_type} CUR file.")
107
+ ctx.current_file_row_count = 0
108
+
109
+ csv_processor.parse(ctx, header: :none) do |row|
110
+ if ctx.current_file_row_count == 1
111
+ ctx.extract_column_headers(row)
112
+ next
113
+ end
114
+
115
+ yield row
116
+ end
117
+ end
118
+
119
+ # @param [String] source_file_loc
120
+ # @return [Pal::Operation::CSVProcessor]
121
+ def _csv_processor(source_file_loc)
122
+ Operation::CSVProcessor.retrieve_default_processor(source_file_loc)
123
+ end
124
+
125
+ # @return [String]
126
+ def _type
127
+ "generic"
128
+ end
129
+ end
130
+
131
+ class AwsCurHandlerImpl < GenericCSVHandlerImpl
132
+ # @return [String]
133
+ def _type
134
+ "aws_cur"
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,8 @@
1
+ {
2
+ "lineItem/UsageStartDate" : {
3
+ "data_type": "datetime"
4
+ },
5
+ "lineItem/BlendedCost" : {
6
+ "data_type": "decimal"
7
+ }
8
+ }
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pal
4
+ module Handler
5
+ class Manager
6
+ include Log
7
+
8
+ attr_accessor :handler
9
+
10
+ # @param [Base] handler
11
+ def initialize(handler)
12
+ raise TypeError.new("Service must be type of BaseServiceImpl") unless handler.is_a? Base
13
+
14
+ @handler = handler
15
+ end
16
+
17
+ # @param [Pal::Request::Runbook] runbook
18
+ # @return [Array, Hash]
19
+ def process_runbook(runbook)
20
+ Pal.logger.info("Beginning execution of playbook ...")
21
+ ctx = @handler.process_runbook
22
+
23
+ log_info "No exporter defined." unless runbook.exporter
24
+ log_info "No candidates found." unless ctx.candidates.size.positive?
25
+
26
+ runbook.exporter.perform_export(ctx)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pal"
4
+ require "pal/operation/processor_context"
5
+
6
+ # Processor for CSV extraction
7
+ module Pal
8
+ module Operation
9
+ # Base class for CSV impls, we can define strategy on memory usage needs based on
10
+ # potential issues from file size.
11
+ # TODO: We probably want to break away from this being a "CSV"-only file type later
12
+ # Needs more thinking
13
+ class CSVProcessor
14
+ include Pal::Log
15
+
16
+ # Strategy to return correct type - memory or performance focused.
17
+ # @return [BaseCSVProcessor]
18
+ def self.retrieve_default_processor(csv_file_location)
19
+ Pal.logger.info("Default processor has been requested. No further action required.")
20
+ RCSVProcessorImpl.new(csv_file_location)
21
+ end
22
+
23
+ attr_accessor :csv_file_location
24
+
25
+ def initialize(csv_file_location)
26
+ @csv_file_location = csv_file_location
27
+ end
28
+
29
+ # @param [ProcessorContext] ctx
30
+ # @param [Proc] block
31
+ # @param [Hash] opts
32
+ def parse(ctx, opts={}, &block)
33
+ _parse_impl(ctx, opts, &block)
34
+ end
35
+
36
+ private
37
+
38
+ # @abstract
39
+ # @param [ProcessorContext] _ctx
40
+ # @param [Hash] _opts
41
+ # @param [Proc] _block
42
+ def _parse_impl(_ctx, _opts, &_block)
43
+ raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
44
+ end
45
+
46
+ # @param [String] file_location
47
+ # @return [String]
48
+ def read_file(file_location)
49
+ log_info("Reading file from disk located at #{file_location}")
50
+ # File.read(File.expand_path(File.join(File.dirname(__FILE__), file_location)))
51
+ Common::LocalFileUtils.read_file(file_location)
52
+ end
53
+
54
+ # @param [String] _file_location
55
+ def stream_file(_file_location)
56
+ raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
57
+ end
58
+ end
59
+
60
+ require "rcsv"
61
+
62
+ # rCSV impl
63
+ class RCSVProcessorImpl < CSVProcessor
64
+ private
65
+
66
+ # @param [ProcessorContext] ctx
67
+ # @param [Proc] _block
68
+ # @param [Hash] opts
69
+ # @yield [Array] row
70
+ # @yield [ProcessorContext] ctx
71
+ # @return [ProcessorContext]
72
+ def _parse_impl(ctx, opts={}, &_block)
73
+ return nil unless block_given?
74
+
75
+ Rcsv.parse(read_file(@csv_file_location), opts) do |row|
76
+ ctx.total_row_count += 1
77
+ ctx.current_file_row_count += 1
78
+
79
+ yield row
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
data/lib/pal/log.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pal"
4
+
5
+ module Pal
6
+ module Log
7
+ # @param [String] message
8
+ def log_debug(message)
9
+ Pal.logger.debug(message)
10
+ end
11
+
12
+ # @param [String] message
13
+ def log_info(message)
14
+ Pal.logger.info(message)
15
+ end
16
+
17
+ # @param [String] message
18
+ def log_warn(message)
19
+ Pal.logger.warn(message)
20
+ end
21
+
22
+ # @param [String] message
23
+ # @param [Exception/Nil] exception
24
+ def log_error(message, exception=nil)
25
+ Pal.logger.error(message)
26
+ Pal.logger.error(exception.backtrace.join("\n")) unless exception.nil?
27
+ end
28
+ end
29
+ end
data/lib/pal/main.rb ADDED
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pal"
4
+ require "pal/configuration"
5
+ require "pal/plugin"
6
+ require "pal/handler/base"
7
+
8
+ module Pal
9
+ class Main
10
+ include Log
11
+ include Plugin
12
+ include Configuration
13
+
14
+ # @return [Pal::Request::Runbook]
15
+ attr_accessor :runbook
16
+
17
+ # @return [Pal::Handler::Manager]
18
+ attr_accessor :manager
19
+
20
+ # @param [Pal::Config] config
21
+ def initialize(config)
22
+ register_config(config)
23
+ end
24
+
25
+ # set config for process
26
+ def setup
27
+ register_plugins
28
+
29
+ @runbook = create_runbook(config.template_file_loc)
30
+ @manager = create_service_manager
31
+ end
32
+
33
+ # @return [Array, Hash]
34
+ def process
35
+ @manager.process_runbook(@runbook)
36
+ end
37
+
38
+ # @param [String] file_location
39
+ # @return [Pal::Request::Runbook]
40
+ def create_runbook(file_location)
41
+ file_relative = file_location.start_with?("/") ? file_location : File.join(File.dirname(__FILE__), file_location)
42
+
43
+ log_debug "Attempting to read file from [#{file_relative}]"
44
+ log_debug "Script executed from [#{__dir__}]"
45
+
46
+ request_content = File.read(file_relative)
47
+ Pal::Request::Runbook.new.from_json(request_content)
48
+ rescue JSON::ParserError => e
49
+ log_error("Malformed JSON request for file [#{file_location}]")
50
+ raise e, "Malformed JSON request for file [#{file_location}]"
51
+ end
52
+
53
+ # @return [Pal::Handler::Manager]
54
+ def create_service_manager
55
+ clazz_name = "Pal::Handler::#{@runbook.metadata.handler}HandlerImpl"
56
+ impl = Kernel.const_get(clazz_name).new(@runbook)
57
+ Pal::Handler::Manager.new(impl)
58
+ rescue NameError => e
59
+ log_error("Cannot find a valid handler impl for #{@runbook.metadata.handler}")
60
+ raise e
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pal"
4
+ require "pal/operation/projection"
5
+
6
+ module Pal
7
+ module Operation
8
+ class Actions
9
+ include ObjectHelpers
10
+ include Log
11
+
12
+ # @return [Array<String>]
13
+ attr_accessor :group_by
14
+
15
+ # @return [String]
16
+ attr_accessor :sort_by
17
+
18
+ # @return [Projection]
19
+ attr_reader :projection
20
+
21
+ def projection=(opts)
22
+ clazz_name = "Pal::Operation::#{opts["type"]&.to_s&.capitalize || "Default"}ProjectionImpl"
23
+ @projection = Kernel.const_get(clazz_name).new(opts["property"] || nil)
24
+ end
25
+
26
+ def processable?
27
+ # Do better in the future
28
+ !@group_by.nil?
29
+ end
30
+
31
+ # @param [Array] rows
32
+ # @param [Hash] column_headers
33
+ # @return [Array] rows, column_headers
34
+ def process(rows, column_headers)
35
+ grouped = perform_group_by(rows, column_headers)
36
+
37
+ return [rows, column_headers] unless @projection&.processable?
38
+
39
+ log_info("Performing projection by [#{@projection.type}].")
40
+ rows, column_headers = perform_projection(grouped, column_headers)
41
+ rows, column_headers = perform_sort_by(rows, column_headers)
42
+
43
+ [rows, column_headers]
44
+ end
45
+
46
+ private
47
+
48
+ # @param [Hash] groups
49
+ # @param [Hash] column_headers
50
+ # @return [Array] rows, column_headers
51
+ def perform_projection(groups, column_headers)
52
+ rows, column_headers = @projection.process(@group_by, groups, column_headers)
53
+ [rows, column_headers]
54
+ end
55
+
56
+ # @param [Array] rows
57
+ # @param [Hash] column_headers
58
+ # @return [Hash] group_by_map
59
+ def perform_group_by(rows, column_headers)
60
+ log_info("Performing grouping by #{@group_by} across a total of #{rows.size} has been provided.")
61
+
62
+ group_by_map = {}
63
+ rows.each do |row|
64
+ key = generate_map_key(row, column_headers)
65
+ group_by_map[key] = [] unless group_by_map.key?(key)
66
+
67
+ group_by_map[key] << row
68
+ end
69
+
70
+ group_by_map
71
+ end
72
+
73
+ # @param [Array] rows
74
+ # @param [Hash] column_headers
75
+ # @return [Array] rows, column_headers
76
+ def perform_sort_by(rows, column_headers)
77
+ log_info("Performing sort by #{@sort_by} across a total of #{rows.size} has been provided.")
78
+ return [rows, column_headers] if @sort_by.nil?
79
+
80
+ sort_idx = column_headers[@sort_by]
81
+
82
+ if sort_idx.nil? || sort_idx.negative?
83
+ raise "Missing [#{@sort_by}]. Valid candidates are: [#{column_headers.keys.join(", ")}]"
84
+ end
85
+
86
+ rows.sort_by! { |a| a[sort_idx] }
87
+
88
+ [rows.reverse, column_headers]
89
+ end
90
+
91
+ # Take a row, extract the props, return a key
92
+ def generate_map_key(row, column_headers)
93
+ keys = []
94
+ @group_by.each do |gbp|
95
+ idx = column_headers[gbp]
96
+
97
+ raise "Missing column index. Please include [#{gbp}] in columns #{column_headers.keys}." unless idx
98
+
99
+ keys << row[idx]
100
+ end
101
+
102
+ keys.join(".")
103
+ end
104
+ end
105
+ end
106
+ end