kubetailrb 0.1.0 → 0.3.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,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kubetailrb/validated'
4
+
5
+ module Kubetailrb
6
+ module Reader
7
+ # Read file content
8
+ class FileReader
9
+ include Validated
10
+
11
+ attr_reader :filepath, :last_nb_lines
12
+
13
+ def initialize(filepath:, last_nb_lines:, follow:)
14
+ @filepath = filepath
15
+ @last_nb_lines = last_nb_lines
16
+ @follow = follow
17
+
18
+ validate
19
+ end
20
+
21
+ def read
22
+ # naive_read
23
+ read_with_fd
24
+ end
25
+
26
+ # NOTE: Is there something like `attr_reader` but for boolean?
27
+ # Nope, Ruby does not know if a variable is a boolean or not. So it cannot
28
+ # create a dedicated method for those booleans.
29
+ def follow?
30
+ @follow
31
+ end
32
+
33
+ private
34
+
35
+ def validate
36
+ raise NoSuchFileError, "#{@filepath} not found" unless File.exist?(@filepath)
37
+
38
+ validate_last_nb_lines @last_nb_lines
39
+ validate_boolean @follow, "Invalid follow: #{@follow}."
40
+ end
41
+
42
+ #
43
+ # Naive implementation to read the last N lines of a file.
44
+ # Does not support `--follow`.
45
+ # Took ~1.41s to read a 3.1G file (5M lines).
46
+ #
47
+ def naive_read
48
+ # Let's us `wc` optimized to count the number of lines!
49
+ nb_lines = `wc -l #{@filepath}`.split.first.to_i
50
+
51
+ start = nb_lines - @last_nb_lines
52
+ i = 0
53
+
54
+ File.open(@filepath, 'r').each_line do |line|
55
+ puts line if i >= start
56
+ i += 1
57
+ end
58
+ end
59
+
60
+ #
61
+ # Use `seek` to start from the EOF.
62
+ # Use `read` to read the content of the file from the given position.
63
+ # src: https://renehernandez.io/tutorials/implementing-tail-command-in-ruby/
64
+ # Took ~0.13s to read a 3.1G file (5M lines).
65
+ #
66
+ def read_with_fd
67
+ file = File.open(@filepath)
68
+ update_stats file
69
+ read_last_nb_lines file
70
+
71
+ if @follow
72
+ loop do
73
+ if file_changed?(file)
74
+ update_stats(file)
75
+ print file.read
76
+ end
77
+ end
78
+ end
79
+ ensure
80
+ file&.close
81
+ end
82
+
83
+ def read_last_nb_lines(file)
84
+ return if File.empty?(file)
85
+
86
+ pos = 0
87
+ current_line_nb = 0
88
+
89
+ loop do
90
+ pos -= 1
91
+ # Seek file position from the end.
92
+ file.seek(pos, IO::SEEK_END)
93
+
94
+ # If we have reached the begining of the file, read all the file.
95
+ # We need to do this check before reading the next byte, otherwise, the
96
+ # cursor will be moved to 1.
97
+ break if file.tell.zero?
98
+
99
+ # Read only one character (or is it byte?).
100
+ char = file.read(1)
101
+ current_line_nb += 1 if char == "\n"
102
+
103
+ break if current_line_nb > @last_nb_lines
104
+ end
105
+
106
+ update_stats file
107
+ puts file.read
108
+ end
109
+
110
+ def update_stats(file)
111
+ @mtime = file.stat.mtime
112
+ @size = file.size
113
+ end
114
+
115
+ def file_changed?(file)
116
+ @mtime != file.stat.mtime || @size != file.size
117
+ end
118
+ end
119
+
120
+ # NOTE: We can create custom exceptions by extending RuntimeError.
121
+ class NoSuchFileError < RuntimeError
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kubetailrb/validated'
4
+ require 'kubetailrb/filter/log_filter'
5
+ require 'kubetailrb/formatter/json_formatter'
6
+ require 'kubetailrb/formatter/no_op_formatter'
7
+ require 'kubetailrb/formatter/pod_metadata_formatter'
8
+ require_relative 'with_k8s_client'
9
+
10
+ module Kubetailrb
11
+ module Reader
12
+ # Read Kubernetes pod logs.
13
+ class K8sPodReader
14
+ include Validated
15
+ include WithK8sClient
16
+
17
+ attr_reader :pod_name, :opts
18
+
19
+ def initialize(pod_name:, container_name:, opts:, k8s_client: nil)
20
+ validate(pod_name, container_name, opts)
21
+
22
+ @k8s_client = k8s_client
23
+ @pod_name = pod_name
24
+ @container_name = container_name
25
+ @formatter = create_formatter(opts, pod_name, container_name)
26
+ @filter = Kubetailrb::Filter::LogFilter.create(opts.excludes)
27
+ @opts = opts
28
+ end
29
+
30
+ def read
31
+ pod_logs = read_pod_logs
32
+ unless @opts.follow?
33
+ print_logs pod_logs
34
+ return
35
+ end
36
+
37
+ # NOTE: The watch method from kubeclient does not accept `tail_lines`
38
+ # argument, so I had to resort to some hack... by using the first log to
39
+ # print out. Not ideal, since it's not really the N last nb lines, and
40
+ # assume every logs are different, which may not be true.
41
+ # But it does the job for most cases.
42
+ first_log_to_display = pod_logs.to_s.split("\n").first
43
+ should_print_logs = false
44
+
45
+ k8s_client.watch_pod_log(@pod_name, @opts.namespace, container: @container_name) do |line|
46
+ # NOTE: Is it good practice to update a variable that is outside of a
47
+ # block? Can we do better?
48
+ should_print_logs = true if line == first_log_to_display
49
+
50
+ print_logs(line) if should_print_logs
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def validate(pod_name, container_name, opts)
57
+ raise_if_blank pod_name, 'Pod name not set.'
58
+ raise_if_blank container_name, 'Container name not set.'
59
+ raise_if_nil opts, 'Opts not set.'
60
+ end
61
+
62
+ def print_logs(logs)
63
+ if logs.to_s.include?("\n")
64
+ logs.to_s.split("\n").each { |log| print_logs(log) }
65
+ return
66
+ end
67
+
68
+ puts @formatter.format(logs) if @filter.test(logs)
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
+ pod_logs = k8s_client.get_pod_log(
77
+ @pod_name,
78
+ @opts.namespace,
79
+ container: @container_name,
80
+ tail_lines: @opts.last_nb_lines
81
+ )
82
+
83
+ if pod_logs.to_s.split("\n").empty?
84
+ raise PodNotReadyError, "No log returned from #{@pod_name}/#{@container_name}"
85
+ end
86
+
87
+ return pod_logs
88
+ rescue Kubeclient::HttpError, PodNotReadyError => e
89
+ puts e.message
90
+ sleep 1
91
+ end
92
+ end
93
+
94
+ def create_formatter(opts, pod_name, container_name)
95
+ formatter = if opts.raw?
96
+ Kubetailrb::Formatter::NoOpFormatter.new
97
+ else
98
+ Kubetailrb::Formatter::JsonFormatter.new(opts.mdcs)
99
+ end
100
+
101
+ if opts.display_names?
102
+ formatter = Kubetailrb::Formatter::PodMetadataFormatter.new(
103
+ pod_name,
104
+ container_name, formatter
105
+ )
106
+ end
107
+
108
+ formatter
109
+ end
110
+ end
111
+
112
+ class PodNotReadyError < RuntimeError
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kubetailrb/validated'
4
+ require 'kubetailrb/k8s_opts'
5
+ require_relative 'k8s_pod_reader'
6
+ require_relative 'with_k8s_client'
7
+
8
+ module Kubetailrb
9
+ module Reader
10
+ # Read multiple pod logs.
11
+ class K8sPodsReader
12
+ include Validated
13
+ include WithK8sClient
14
+ include Painter
15
+
16
+ attr_reader :pod_query, :container_query, :opts
17
+
18
+ def initialize(pod_query:, container_query:, opts:, k8s_client: nil)
19
+ validate(pod_query, container_query, opts)
20
+
21
+ @k8s_client = k8s_client
22
+ @pod_query = Regexp.new(pod_query)
23
+ @container_query = Regexp.new(container_query)
24
+ @opts = opts
25
+ end
26
+
27
+ def read
28
+ pods = find_pods
29
+ watch_for_new_pod_events if @opts.follow?
30
+
31
+ threads = pods.flat_map do |pod|
32
+ pod.spec.containers.select { |container| applicable_container?(container.name) }.map do |container|
33
+ # NOTE: How much memory does a Ruby Thread takes? Can we spawn hundreds
34
+ # to thoudsands of Threads without issue?
35
+ start_reading_pod_logs(pod.metadata.name, container.name)
36
+ end
37
+ end
38
+
39
+ # NOTE: '&:' is a shorthand way of calling 'join' method on each thread.
40
+ # It's equivalent to: threads.each { |thread| thread.join }
41
+ threads.each(&:join)
42
+ end
43
+
44
+ private
45
+
46
+ def validate(pod_query, container_query, opts)
47
+ raise_if_blank pod_query, 'Pod query not set.'
48
+ raise_if_blank container_query, 'Container query not set.'
49
+ raise_if_nil opts, 'Opts not set.'
50
+ end
51
+
52
+ def find_pods
53
+ k8s_client
54
+ .get_pods(namespace: @opts.namespace)
55
+ .select { |pod| applicable_pod?(pod) }
56
+ end
57
+
58
+ def create_reader(pod_name, container_name)
59
+ K8sPodReader.new(
60
+ k8s_client: k8s_client,
61
+ pod_name: pod_name,
62
+ container_name: container_name,
63
+ opts: @opts
64
+ )
65
+ end
66
+
67
+ #
68
+ # Watch any pod events, and if there's another pod that validates the pod
69
+ # query, then let's read the pod logs!
70
+ #
71
+ def watch_for_new_pod_events
72
+ k8s_client.watch_pods(namespace: @opts.namespace) do |notice|
73
+ next unless applicable_pod?(notice.object)
74
+
75
+ on_new_pod_event notice if new_pod_event?(notice)
76
+ on_deleted_pod_event notice if deleted_pod_event?(notice)
77
+ end
78
+ end
79
+
80
+ def applicable_pod?(pod)
81
+ pod.metadata.name.match?(@pod_query)
82
+ end
83
+
84
+ def applicable_container?(container_name)
85
+ container_name.match?(@container_query)
86
+ end
87
+
88
+ def new_pod_event?(notice)
89
+ notice.type == 'ADDED' && notice.object.kind == 'Pod'
90
+ end
91
+
92
+ def on_new_pod_event(notice) # rubocop:disable Metrics/AbcSize
93
+ # NOTE: We are in another thread (are we?), so no sense to use
94
+ # 'Thread.join' here.
95
+
96
+ notice.object.spec.containers.map do |container|
97
+ next unless applicable_container?(container.name)
98
+
99
+ # NOTE: How much memory does a Ruby Thread takes? Can we spawn hundreds
100
+ # to thoudsands of Threads without issue?
101
+ puts blue("+ #{notice.object.metadata.name}/#{container.name}")
102
+ start_reading_pod_logs(notice.object.metadata.name, container.name)
103
+ end
104
+ end
105
+
106
+ def start_reading_pod_logs(pod_name, container_name)
107
+ Thread.new { create_reader(pod_name, container_name).read }
108
+ end
109
+
110
+ def deleted_pod_event?(notice)
111
+ notice.type == 'DELETED' && notice.object.kind == 'Pod'
112
+ end
113
+
114
+ def on_deleted_pod_event(notice)
115
+ notice.object.spec.containers.map do |container|
116
+ next unless applicable_container?(container.name)
117
+
118
+ puts red("- #{notice.object.metadata.name}/#{container.name}")
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kubeclient'
4
+
5
+ module Kubetailrb
6
+ module Reader
7
+ # Add behavior to get a k8s client by using composition.
8
+ # NOTE: Is it the idiomatic way? Or shall I use a factory? Or is there a
9
+ # better way?
10
+ module WithK8sClient
11
+ def k8s_client
12
+ @k8s_client ||= create_k8s_client
13
+ end
14
+
15
+ def create_k8s_client
16
+ config = Kubeclient::Config.read(ENV['KUBECONFIG'] || "#{ENV["HOME"]}/.kube/config")
17
+ context = config.context
18
+ Kubeclient::Client.new(
19
+ context.api_endpoint,
20
+ 'v1',
21
+ ssl_options: context.ssl_options,
22
+ auth_options: context.auth_options
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
@@ -7,6 +7,10 @@ module Kubetailrb
7
7
  raise ArgumentError, error_message if arg.nil? || arg.strip&.empty?
8
8
  end
9
9
 
10
+ def raise_if_nil(arg, error_message)
11
+ raise ArgumentError, error_message if arg.nil?
12
+ end
13
+
10
14
  def validate_last_nb_lines(last_nb_lines)
11
15
  last_nb_lines_valid = last_nb_lines.is_a?(Integer) && last_nb_lines.positive?
12
16
  raise ArgumentError, "Invalid last_nb_lines: #{last_nb_lines}." unless last_nb_lines_valid
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kubetailrb
4
- VERSION = '0.1.0'
4
+ VERSION = '0.3.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kubetailrb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Louis Lin
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-11-29 00:00:00.000000000 Z
11
+ date: 2024-12-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: kubeclient
@@ -112,30 +112,30 @@ dependencies:
112
112
  name: pry
113
113
  requirement: !ruby/object:Gem::Requirement
114
114
  requirements:
115
- - - ">="
115
+ - - "~>"
116
116
  - !ruby/object:Gem::Version
117
- version: '0'
117
+ version: 0.14.2
118
118
  type: :development
119
119
  prerelease: false
120
120
  version_requirements: !ruby/object:Gem::Requirement
121
121
  requirements:
122
- - - ">="
122
+ - - "~>"
123
123
  - !ruby/object:Gem::Version
124
- version: '0'
124
+ version: 0.14.2
125
125
  - !ruby/object:Gem::Dependency
126
126
  name: pry-byebug
127
127
  requirement: !ruby/object:Gem::Requirement
128
128
  requirements:
129
- - - ">="
129
+ - - "~>"
130
130
  - !ruby/object:Gem::Version
131
- version: '0'
131
+ version: 3.10.1
132
132
  type: :development
133
133
  prerelease: false
134
134
  version_requirements: !ruby/object:Gem::Requirement
135
135
  requirements:
136
- - - ">="
136
+ - - "~>"
137
137
  - !ruby/object:Gem::Version
138
- version: '0'
138
+ version: 3.10.1
139
139
  - !ruby/object:Gem::Dependency
140
140
  name: rake
141
141
  requirement: !ruby/object:Gem::Requirement
@@ -253,16 +253,19 @@ files:
253
253
  - lib/kubetailrb/cmd/help.rb
254
254
  - lib/kubetailrb/cmd/k8s.rb
255
255
  - lib/kubetailrb/cmd/version.rb
256
- - lib/kubetailrb/file_reader.rb
257
- - lib/kubetailrb/json_formatter.rb
256
+ - lib/kubetailrb/filter/log_filter.rb
257
+ - lib/kubetailrb/formatter/json_formatter.rb
258
+ - lib/kubetailrb/formatter/no_op_formatter.rb
259
+ - lib/kubetailrb/formatter/pod_metadata_formatter.rb
258
260
  - lib/kubetailrb/k8s_opts.rb
259
- - lib/kubetailrb/k8s_pod_reader.rb
260
- - lib/kubetailrb/k8s_pods_reader.rb
261
- - lib/kubetailrb/no_op_formatter.rb
262
261
  - lib/kubetailrb/opts_parser.rb
262
+ - lib/kubetailrb/painter.rb
263
+ - lib/kubetailrb/reader/file_reader.rb
264
+ - lib/kubetailrb/reader/k8s_pod_reader.rb
265
+ - lib/kubetailrb/reader/k8s_pods_reader.rb
266
+ - lib/kubetailrb/reader/with_k8s_client.rb
263
267
  - lib/kubetailrb/validated.rb
264
268
  - lib/kubetailrb/version.rb
265
- - lib/kubetailrb/with_k8s_client.rb
266
269
  - sig/kubetailrb.rbs
267
270
  homepage: https://github.com/l-lin/kubetailrb
268
271
  licenses:
@@ -1,122 +0,0 @@
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
@@ -1,66 +0,0 @@
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