kubetailrb 0.1.0

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.
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kubetailrb
4
+ module Cmd
5
+ # Display help.
6
+ class Help
7
+ FLAGS = %w[-h --help].freeze
8
+
9
+ def execute
10
+ puts <<~HELP
11
+ Tail your Kubernetes pod logs at the same time.
12
+
13
+ Usage:
14
+ kubetailrb pod-query [flags]
15
+
16
+ Flags:
17
+ -v, --version Display version.
18
+ -h, --help Display help.
19
+ --tail The number of lines from the end of the logs to show. Defaults to 10.
20
+ -f, --follow Output appended data as the file grows.
21
+ --file Display file content.
22
+ -p, --pretty Pretty print JSON logs.
23
+ -r, --raw Only display pod logs.
24
+ -n, --namespace Kubernetes namespace to use.
25
+ HELP
26
+ end
27
+
28
+ class << self
29
+ def applicable?(*args)
30
+ missing_args?(*args) || contains_flags?(*args)
31
+ end
32
+
33
+ private
34
+
35
+ def missing_args?(*args)
36
+ args.nil? || args.empty?
37
+ end
38
+
39
+ def contains_flags?(*args)
40
+ args.any? { |arg| FLAGS.include?(arg) }
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kubetailrb/k8s_pods_reader'
4
+ require 'kubetailrb/json_formatter'
5
+ require 'kubetailrb/no_op_formatter'
6
+
7
+ module Kubetailrb
8
+ module Cmd
9
+ # Command to read k8s pod logs.
10
+ class K8s
11
+ DEFAULT_NB_LINES = 10
12
+ DEFAULT_NAMESPACE = 'default'
13
+
14
+ NAMESPACE_FLAGS = %w[-n --namespace].freeze
15
+ TAIL_FLAG = '--tail'
16
+ FOLLOW_FLAGS = %w[-f --follow].freeze
17
+ RAW_FLAGS = %w[-r --raw].freeze
18
+ PRETTY_PRINT_FLAGS = %w[-p --pretty].freeze
19
+
20
+ attr_reader :reader
21
+
22
+ def initialize(pod_query:, opts:, formatter:)
23
+ @reader = Kubetailrb::K8sPodsReader.new(pod_query: pod_query, formatter: formatter, opts: opts)
24
+ end
25
+
26
+ def execute
27
+ @reader.read
28
+ end
29
+
30
+ class << self
31
+ def create(*args)
32
+ new(
33
+ pod_query: parse_pod_query(*args),
34
+ formatter: parse_pretty_print(*args) ? Kubetailrb::JsonFormatter.new : Kubetailrb::NoOpFormatter.new,
35
+ opts: K8sOpts.new(
36
+ namespace: parse_namespace(*args),
37
+ last_nb_lines: parse_nb_lines(*args),
38
+ follow: parse_follow(*args),
39
+ raw: parse_raw(*args)
40
+ )
41
+ )
42
+ end
43
+
44
+ def parse_pod_query(*args)
45
+ # TODO: We could be smarter here? For example, if the pod names are
46
+ # provided at the end of the command, like this:
47
+ # kubetailrb --tail 3 some-pod
48
+ # The above command will not work because this method will return 3
49
+ # instead of 'some-pod'...
50
+ args.find { |arg| !arg.start_with? '-' }
51
+ end
52
+
53
+ #
54
+ # Parse k8s namespace from arguments provided in the CLI, e.g.
55
+ #
56
+ # kubetailrb some-pod -n sandbox
57
+ #
58
+ # will return 'sandbox'.
59
+ #
60
+ # Will raise `MissingNamespaceValueError` if the value is not provided:
61
+ #
62
+ # kubetailrb some-pod -n
63
+ #
64
+ def parse_namespace(*args)
65
+ return DEFAULT_NAMESPACE unless args.any? { |arg| NAMESPACE_FLAGS.include?(arg) }
66
+
67
+ index = args.find_index { |arg| NAMESPACE_FLAGS.include?(arg) }.to_i
68
+
69
+ raise MissingNamespaceValueError, "Missing #{NAMESPACE_FLAGS} value." if args[index + 1].nil?
70
+
71
+ args[index + 1]
72
+ end
73
+
74
+ #
75
+ # Parse nb lines from arguments provided in the CLI, e.g.
76
+ #
77
+ # kubetailrb some-pod --tail 3
78
+ #
79
+ # will return 3.
80
+ #
81
+ # Will raise `MissingNbLinesValueError` if the value is not provided:
82
+ #
83
+ # kubetailrb some-pod --tail
84
+ #
85
+ # Will raise `InvalidNbLinesValueError` if the provided value is not a
86
+ # number:
87
+ #
88
+ # kubetailrb some-pod --tail some-string
89
+ #
90
+ def parse_nb_lines(*args)
91
+ return DEFAULT_NB_LINES unless args.include?(TAIL_FLAG)
92
+
93
+ index = args.find_index { |arg| arg == TAIL_FLAG }.to_i
94
+
95
+ raise MissingNbLinesValueError, "Missing #{TAIL_FLAG} value." if args[index + 1].nil?
96
+
97
+ last_nb_lines = args[index + 1].to_i
98
+
99
+ raise InvalidNbLinesValueError, "Invalid #{TAIL_FLAG} value: #{args[index + 1]}." if last_nb_lines.zero?
100
+
101
+ last_nb_lines
102
+ end
103
+
104
+ def parse_follow(*args)
105
+ args.any? { |arg| FOLLOW_FLAGS.include?(arg) }
106
+ end
107
+
108
+ def parse_raw(*args)
109
+ args.any? { |arg| RAW_FLAGS.include?(arg) }
110
+ end
111
+
112
+ def parse_pretty_print(*args)
113
+ args.any? { |arg| PRETTY_PRINT_FLAGS.include?(arg) }
114
+ end
115
+ end
116
+ end
117
+
118
+ class MissingNbLinesValueError < RuntimeError
119
+ end
120
+
121
+ class MissingNamespaceValueError < RuntimeError
122
+ end
123
+
124
+ class InvalidNbLinesValueError < RuntimeError
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kubetailrb
4
+ module Cmd
5
+ # Get application version.
6
+ class Version
7
+ FLAGS = %w[-v --version].freeze
8
+
9
+ def execute
10
+ puts VERSION
11
+ end
12
+
13
+ def self.applicable?(*args)
14
+ args.any? { |arg| FLAGS.include?(arg) }
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'validated'
4
+
5
+ module Kubetailrb
6
+ # Read file content
7
+ class FileReader
8
+ include Validated
9
+
10
+ attr_reader :filepath, :last_nb_lines
11
+
12
+ def initialize(filepath:, last_nb_lines:, follow:)
13
+ @filepath = filepath
14
+ @last_nb_lines = last_nb_lines
15
+ @follow = follow
16
+
17
+ validate
18
+ end
19
+
20
+ def read
21
+ # naive_read
22
+ read_with_fd
23
+ end
24
+
25
+ # NOTE: Is there something like `attr_reader` but for boolean?
26
+ # Nope, Ruby does not know if a variable is a boolean or not. So it cannot
27
+ # create a dedicated method for those booleans.
28
+ def follow?
29
+ @follow
30
+ end
31
+
32
+ private
33
+
34
+ def validate
35
+ raise NoSuchFileError, "#{@filepath} not found" unless File.exist?(@filepath)
36
+
37
+ validate_last_nb_lines @last_nb_lines
38
+ validate_boolean @follow, "Invalid follow: #{@follow}."
39
+ end
40
+
41
+ #
42
+ # Naive implementation to read the last N lines of a file.
43
+ # Does not support `--follow`.
44
+ # Took ~1.41s to read a 3.1G file (5M lines).
45
+ #
46
+ def naive_read
47
+ # Let's us `wc` optimized to count the number of lines!
48
+ nb_lines = `wc -l #{@filepath}`.split.first.to_i
49
+
50
+ start = nb_lines - @last_nb_lines
51
+ i = 0
52
+
53
+ File.open(@filepath, 'r').each_line do |line|
54
+ puts line if i >= start
55
+ i += 1
56
+ end
57
+ end
58
+
59
+ #
60
+ # Use `seek` to start from the EOF.
61
+ # Use `read` to read the content of the file from the given position.
62
+ # src: https://renehernandez.io/tutorials/implementing-tail-command-in-ruby/
63
+ # Took ~0.13s to read a 3.1G file (5M lines).
64
+ #
65
+ def read_with_fd
66
+ file = File.open(@filepath)
67
+ update_stats file
68
+ read_last_nb_lines file
69
+
70
+ if @follow
71
+ loop do
72
+ if file_changed?(file)
73
+ update_stats(file)
74
+ print file.read
75
+ end
76
+ end
77
+ end
78
+ ensure
79
+ file&.close
80
+ end
81
+
82
+ def read_last_nb_lines(file)
83
+ return if File.empty?(file)
84
+
85
+ pos = 0
86
+ current_line_nb = 0
87
+
88
+ loop do
89
+ pos -= 1
90
+ # Seek file position from the end.
91
+ file.seek(pos, IO::SEEK_END)
92
+
93
+ # If we have reached the begining of the file, read all the file.
94
+ # We need to do this check before reading the next byte, otherwise, the
95
+ # cursor will be moved to 1.
96
+ break if file.tell.zero?
97
+
98
+ # Read only one character (or is it byte?).
99
+ char = file.read(1)
100
+ current_line_nb += 1 if char == "\n"
101
+
102
+ break if current_line_nb > @last_nb_lines
103
+ end
104
+
105
+ update_stats file
106
+ puts file.read
107
+ end
108
+
109
+ def update_stats(file)
110
+ @mtime = file.stat.mtime
111
+ @size = file.size
112
+ end
113
+
114
+ def file_changed?(file)
115
+ @mtime != file.stat.mtime || @size != file.size
116
+ end
117
+ end
118
+
119
+ # NOTE: We can create custom exceptions by extending RuntimeError.
120
+ class NoSuchFileError < RuntimeError
121
+ end
122
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kubetailrb
4
+ # Format JSON to human readable.
5
+ class JsonFormatter
6
+ def format(log)
7
+ json = JSON.parse(log)
8
+
9
+ return format_access_log(json) if access_log?(json)
10
+
11
+ format_application_log(json)
12
+ rescue JSON::ParserError
13
+ log
14
+ end
15
+
16
+ private
17
+
18
+ def access_log?(json)
19
+ json.include?('http.response.status_code')
20
+ end
21
+
22
+ def format_access_log(json)
23
+ "#{json["@timestamp"]}#{http_status_code json}#{json["http.request.method"]} #{json["url.path"]}"
24
+ end
25
+
26
+ def format_application_log(json)
27
+ "#{json["@timestamp"]}#{log_level json}#{json["message"]}"
28
+ end
29
+
30
+ def http_status_code(json)
31
+ code = json['http.response.status_code']
32
+
33
+ return "#{blue(" I ")}[#{code}] " if code >= 200 && code < 400
34
+ return "#{yellow(" W ")}[#{code}] " if code >= 400 && code < 500
35
+ return "#{red(" E ")}[#{code}] " if code > 500
36
+
37
+ " #{code} "
38
+ end
39
+
40
+ def log_level(json)
41
+ level = json['log.level']
42
+ return '' if level.nil? || level.strip.empty?
43
+ return blue(' I ') if level == 'INFO'
44
+ return yellow(' W ') if level == 'WARN'
45
+ return red(' E ') if level == 'ERROR'
46
+
47
+ " #{level} "
48
+ end
49
+
50
+ def colorize(text, color_code)
51
+ " \e[#{color_code}m#{text}\e[0m "
52
+ end
53
+
54
+ def blue(text)
55
+ colorize(text, '1;30;44')
56
+ end
57
+
58
+ def yellow(text)
59
+ colorize(text, '1;30;43')
60
+ end
61
+
62
+ def red(text)
63
+ colorize(text, '1;30;41')
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'validated'
4
+
5
+ module Kubetailrb
6
+ # Options to use for reading k8s pod logs.
7
+ class K8sOpts
8
+ include Validated
9
+
10
+ attr_reader :namespace, :last_nb_lines
11
+
12
+ def initialize(namespace:, last_nb_lines:, follow:, raw:)
13
+ @namespace = namespace
14
+ @last_nb_lines = last_nb_lines
15
+ @follow = follow
16
+ @raw = raw
17
+
18
+ validate
19
+ end
20
+
21
+ def follow?
22
+ @follow
23
+ end
24
+
25
+ def raw?
26
+ @raw
27
+ end
28
+
29
+ private
30
+
31
+ def validate
32
+ raise_if_blank @namespace, 'Namespace not set.'
33
+ validate_last_nb_lines @last_nb_lines
34
+ validate_boolean @follow, "Invalid follow: #{@follow}."
35
+ validate_boolean @raw, "Invalid raw: #{@raw}."
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'with_k8s_client'
4
+ require_relative 'validated'
5
+ require_relative 'json_formatter'
6
+
7
+ module Kubetailrb
8
+ # Read Kubernetes pod logs.
9
+ class K8sPodReader
10
+ include Validated
11
+ include WithK8sClient
12
+
13
+ attr_reader :pod_name, :opts
14
+
15
+ def initialize(pod_name:, formatter:, opts:, k8s_client: nil)
16
+ validate(pod_name, formatter, opts)
17
+
18
+ @k8s_client = k8s_client
19
+ @pod_name = pod_name
20
+ @formatter = formatter
21
+ @opts = opts
22
+ end
23
+
24
+ def read
25
+ pod_logs = read_pod_logs
26
+ unless @opts.follow?
27
+ print_logs pod_logs
28
+ return
29
+ end
30
+
31
+ # NOTE: The watch method from kubeclient does not accept `tail_lines`
32
+ # argument, so I had to resort to some hack... by using the first log to
33
+ # print out. Not ideal, since it's not really the N last nb lines, and
34
+ # assume every logs are different, which may not be true.
35
+ # But it does the job for most cases.
36
+ first_log_to_display = pod_logs.to_s.split("\n").first
37
+ should_print_logs = false
38
+
39
+ k8s_client.watch_pod_log(@pod_name, @opts.namespace) do |line|
40
+ # NOTE: Is it good practice to update a variable that is outside of a
41
+ # block? Can we do better?
42
+ should_print_logs = true if line == first_log_to_display
43
+
44
+ print_logs(line) if should_print_logs
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def validate(pod_name, formatter, opts)
51
+ raise_if_blank pod_name, 'Pod name not set.'
52
+
53
+ raise ArgumentError, 'Formatter not set.' if formatter.nil?
54
+
55
+ raise ArgumentError, 'Opts not set.' if opts.nil?
56
+ end
57
+
58
+ def print_logs(logs)
59
+ if logs.to_s.include?("\n")
60
+ logs.to_s.split("\n").each { |log| print_logs(log) }
61
+ return
62
+ end
63
+
64
+ if @opts.raw?
65
+ puts @formatter.format(logs)
66
+ else
67
+ puts "#{@pod_name} - #{@formatter.format logs}"
68
+ end
69
+ $stdout.flush
70
+ end
71
+
72
+ def read_pod_logs
73
+ # The pod may still not up/ready, so small hack to retry 120 times (number
74
+ # taken randomly) until the pod returns its logs.
75
+ 120.times do
76
+ return k8s_client.get_pod_log(@pod_name, @opts.namespace, tail_lines: @opts.last_nb_lines)
77
+ rescue Kubeclient::HttpError => e
78
+ puts e.message
79
+ sleep 1
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'k8s_opts'
4
+ require_relative 'k8s_pod_reader'
5
+ require_relative 'with_k8s_client'
6
+
7
+ module Kubetailrb
8
+ # Read multiple pod logs.
9
+ class K8sPodsReader
10
+ include Validated
11
+ include WithK8sClient
12
+
13
+ attr_reader :pod_query, :opts
14
+
15
+ def initialize(pod_query:, formatter:, opts:, k8s_client: nil)
16
+ validate(pod_query, formatter, opts)
17
+
18
+ @k8s_client = k8s_client
19
+ @pod_query = Regexp.new(pod_query)
20
+ @formatter = formatter
21
+ @opts = opts
22
+ end
23
+
24
+ def read
25
+ pods = find_pods
26
+ watch_for_new_pod_events if @opts.follow?
27
+
28
+ threads = pods.map do |pod|
29
+ # NOTE: How much memory does a Ruby Thread takes? Can we spawn hundreds
30
+ # to thoudsands of Threads without issue?
31
+ Thread.new { create_reader(pod.metadata.name).read }
32
+ end
33
+
34
+ # NOTE: '&:' is a shorthand way of calling 'join' method on each thread.
35
+ # It's equivalent to: threads.each { |thread| thread.join }
36
+ threads.each(&:join)
37
+ end
38
+
39
+ private
40
+
41
+ def validate(pod_query, formatter, opts)
42
+ raise_if_blank pod_query, 'Pod query not set.'
43
+
44
+ raise ArgumentError, 'Formatter not set.' if formatter.nil?
45
+
46
+ raise ArgumentError, 'Opts not set.' if opts.nil?
47
+ end
48
+
49
+ def find_pods
50
+ k8s_client
51
+ .get_pods(namespace: @opts.namespace)
52
+ .select { |pod| applicable?(pod) }
53
+ end
54
+
55
+ def create_reader(pod_name)
56
+ K8sPodReader.new(
57
+ k8s_client: k8s_client,
58
+ pod_name: pod_name,
59
+ formatter: @formatter,
60
+ opts: @opts
61
+ )
62
+ end
63
+
64
+ #
65
+ # Watch any pod events, and if there's another pod that validates the pod
66
+ # query, then let's read the pod logs!
67
+ #
68
+ def watch_for_new_pod_events
69
+ k8s_client.watch_pods(namespace: @opts.namespace) do |notice|
70
+ if new_pod_event?(notice) && applicable?(notice.object)
71
+ # NOTE: We are in another thread (are we?), so no sense to use
72
+ # 'Thread.join' here.
73
+ Thread.new { create_reader(notice.object.metadata.name).read }
74
+ end
75
+ end
76
+ end
77
+
78
+ def applicable?(pod)
79
+ pod.metadata.name.match?(@pod_query)
80
+ end
81
+
82
+ def new_pod_event?(notice)
83
+ notice.type == 'ADDED' && notice.object.kind == 'Pod'
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kubetailrb
4
+ # Formatter that does nothing except return what's given to it.
5
+ class NoOpFormatter
6
+ def format(log)
7
+ log
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cmd/file'
4
+ require_relative 'cmd/help'
5
+ require_relative 'cmd/k8s'
6
+ require_relative 'cmd/version'
7
+
8
+ module Kubetailrb
9
+ # Parse CLI arguments and flags.
10
+ # NOTE: We could use the standard library optparse (OptionParser) or a more
11
+ # comprehensive tool like Thor to achieve this, but that would defeat the
12
+ # purpose of learning by implementing it ourselves.
13
+ class OptsParser
14
+ def initialize(*args)
15
+ @args = *args
16
+ end
17
+
18
+ def parse
19
+ return Cmd::Help.new if Cmd::Help.applicable?(*@args)
20
+
21
+ return Cmd::Version.new if Cmd::Version.applicable?(*@args)
22
+
23
+ return Cmd::File.create(*@args) if Cmd::File.applicable?(*@args)
24
+
25
+ Cmd::K8s.create(*@args)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kubetailrb
4
+ # Add behaviors to validate the invariants.
5
+ module Validated
6
+ def raise_if_blank(arg, error_message)
7
+ raise ArgumentError, error_message if arg.nil? || arg.strip&.empty?
8
+ end
9
+
10
+ def validate_last_nb_lines(last_nb_lines)
11
+ last_nb_lines_valid = last_nb_lines.is_a?(Integer) && last_nb_lines.positive?
12
+ raise ArgumentError, "Invalid last_nb_lines: #{last_nb_lines}." unless last_nb_lines_valid
13
+ end
14
+
15
+ def validate_boolean(follow, error_message)
16
+ raise ArgumentError, error_message unless follow.is_a?(Boolean)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kubetailrb
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kubeclient'
4
+
5
+ module Kubetailrb
6
+ # Add behavior to get a k8s client by using composition.
7
+ # NOTE: Is it the idiomatic way? Or shall I use a factory? Or is there a
8
+ # better way?
9
+ module WithK8sClient
10
+ def k8s_client
11
+ @k8s_client ||= create_k8s_client
12
+ end
13
+
14
+ def create_k8s_client
15
+ config = Kubeclient::Config.read(ENV['KUBECONFIG'] || "#{ENV["HOME"]}/.kube/config")
16
+ context = config.context
17
+ Kubeclient::Client.new(
18
+ context.api_endpoint,
19
+ 'v1',
20
+ ssl_options: context.ssl_options,
21
+ auth_options: context.auth_options
22
+ )
23
+ end
24
+ end
25
+ end
data/lib/kubetailrb.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'boolean'
4
+ require_relative 'kubetailrb/cli'
5
+ require_relative 'kubetailrb/version'
6
+
7
+ # Main module
8
+ module Kubetailrb
9
+ end
@@ -0,0 +1,4 @@
1
+ module Kubetailrb
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end