tnw 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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +56 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/tnw/application.css +183 -0
- data/app/controllers/tnw/application_controller.rb +32 -0
- data/app/controllers/tnw/dashboard_controller.rb +14 -0
- data/app/controllers/tnw/internal/metric_samples_controller.rb +62 -0
- data/app/helpers/tnw/application_helper.rb +21 -0
- data/app/jobs/tnw/application_job.rb +4 -0
- data/app/jobs/tnw/metrics/retention_job.rb +10 -0
- data/app/models/tnw/application_record.rb +5 -0
- data/app/models/tnw/metric_sample.rb +16 -0
- data/app/models/tnw/server.rb +16 -0
- data/app/presenters/tnw/server_metric_status.rb +32 -0
- data/app/services/tnw/metric_samples/ingest.rb +160 -0
- data/app/views/layouts/tnw/application.html.erb +15 -0
- data/app/views/tnw/dashboard/index.html.erb +62 -0
- data/config/routes.rb +6 -0
- data/db/migrate/20260711000001_create_tnw_servers.rb +15 -0
- data/db/migrate/20260711000002_create_tnw_metric_samples.rb +33 -0
- data/lib/generators/tnw/install/install_generator.rb +44 -0
- data/lib/generators/tnw/install/templates/tnw.rb +12 -0
- data/lib/tasks/tnw_tasks.rake +4 -0
- data/lib/tnw/configuration.rb +95 -0
- data/lib/tnw/engine.rb +5 -0
- data/lib/tnw/metrics/ingestion_error.rb +12 -0
- data/lib/tnw/metrics/parse_error.rb +6 -0
- data/lib/tnw/metrics/parsers/df.rb +37 -0
- data/lib/tnw/metrics/parsers/proc_loadavg.rb +20 -0
- data/lib/tnw/metrics/parsers/proc_meminfo.rb +37 -0
- data/lib/tnw/metrics/parsers/proc_stat.rb +37 -0
- data/lib/tnw/metrics/replay_guard.rb +17 -0
- data/lib/tnw/metrics/request_verifier.rb +59 -0
- data/lib/tnw/metrics.rb +14 -0
- data/lib/tnw/version.rb +3 -0
- data/lib/tnw.rb +21 -0
- metadata +97 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<main>
|
|
2
|
+
<header class="tnw-page-header">
|
|
3
|
+
<div>
|
|
4
|
+
<p class="tnw-eyebrow">The Night Watch</p>
|
|
5
|
+
<h1>Server health</h1>
|
|
6
|
+
</div>
|
|
7
|
+
<span class="tnw-server-count"><%= pluralize(@server_metrics.size, "server") %></span>
|
|
8
|
+
</header>
|
|
9
|
+
|
|
10
|
+
<% if @server_metrics.empty? %>
|
|
11
|
+
<section class="tnw-empty-state">
|
|
12
|
+
<h2>No servers registered</h2>
|
|
13
|
+
</section>
|
|
14
|
+
<% else %>
|
|
15
|
+
<section class="tnw-server-grid">
|
|
16
|
+
<% @server_metrics.each do |server_metrics| %>
|
|
17
|
+
<% sample = server_metrics.sample %>
|
|
18
|
+
<article class="tnw-server" data-status="<%= server_metrics.status %>">
|
|
19
|
+
<header class="tnw-server-header">
|
|
20
|
+
<div>
|
|
21
|
+
<h2><%= server_metrics.server.display_name %></h2>
|
|
22
|
+
<p><%= server_metrics.server.identifier %></p>
|
|
23
|
+
</div>
|
|
24
|
+
<span class="tnw-status"><%= server_metrics.status_label %></span>
|
|
25
|
+
</header>
|
|
26
|
+
|
|
27
|
+
<dl class="tnw-metrics">
|
|
28
|
+
<div>
|
|
29
|
+
<dt>CPU</dt>
|
|
30
|
+
<dd><%= metric_percentage(sample&.cpu_percent) %></dd>
|
|
31
|
+
</div>
|
|
32
|
+
<div>
|
|
33
|
+
<dt>Memory used</dt>
|
|
34
|
+
<dd><%= metric_bytes(sample&.memory_used_bytes) %></dd>
|
|
35
|
+
</div>
|
|
36
|
+
<div>
|
|
37
|
+
<dt>Disk used</dt>
|
|
38
|
+
<dd><%= metric_bytes(sample&.disk_used_bytes) %></dd>
|
|
39
|
+
</div>
|
|
40
|
+
<div>
|
|
41
|
+
<dt>Load 1m</dt>
|
|
42
|
+
<dd><%= metric_decimal(sample&.load_1m) %></dd>
|
|
43
|
+
</div>
|
|
44
|
+
</dl>
|
|
45
|
+
|
|
46
|
+
<footer class="tnw-server-footer">
|
|
47
|
+
<% if sample %>
|
|
48
|
+
Last received <time datetime="<%= sample.received_at.iso8601 %>"><%= time_ago_in_words(sample.received_at) %> ago</time>
|
|
49
|
+
<% else %>
|
|
50
|
+
Waiting for first sample
|
|
51
|
+
<% end %>
|
|
52
|
+
<% if server_metrics.server.last_ingestion_error_code.present? %>
|
|
53
|
+
<span>Error: <%= server_metrics.server.last_ingestion_error_code.humanize %></span>
|
|
54
|
+
<% elsif sample&.parse_errors&.any? %>
|
|
55
|
+
<span>Partial: <%= sample.parse_errors.map(&:humanize).join(", ") %></span>
|
|
56
|
+
<% end %>
|
|
57
|
+
</footer>
|
|
58
|
+
</article>
|
|
59
|
+
<% end %>
|
|
60
|
+
</section>
|
|
61
|
+
<% end %>
|
|
62
|
+
</main>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class CreateTnwServers < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :tnw_servers do |t|
|
|
4
|
+
t.string :identifier, null: false
|
|
5
|
+
t.string :name
|
|
6
|
+
t.boolean :enabled, null: false, default: true
|
|
7
|
+
t.string :last_ingestion_status
|
|
8
|
+
t.string :last_ingestion_error_code
|
|
9
|
+
t.datetime :last_ingestion_at
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :tnw_servers, :identifier, unique: true
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
class CreateTnwMetricSamples < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :tnw_metric_samples do |t|
|
|
4
|
+
t.references :server, null: false, foreign_key: { to_table: :tnw_servers }
|
|
5
|
+
t.datetime :collected_at, null: false
|
|
6
|
+
t.datetime :received_at, null: false
|
|
7
|
+
t.decimal :cpu_percent, precision: 5, scale: 2
|
|
8
|
+
t.decimal :load_1m, precision: 10, scale: 3
|
|
9
|
+
t.decimal :load_5m, precision: 10, scale: 3
|
|
10
|
+
t.decimal :load_15m, precision: 10, scale: 3
|
|
11
|
+
t.bigint :memory_total_bytes
|
|
12
|
+
t.bigint :memory_used_bytes
|
|
13
|
+
t.bigint :memory_available_bytes
|
|
14
|
+
t.bigint :disk_total_bytes
|
|
15
|
+
t.bigint :disk_used_bytes
|
|
16
|
+
t.bigint :disk_available_bytes
|
|
17
|
+
t.string :disk_mount
|
|
18
|
+
t.string :collector_name, null: false
|
|
19
|
+
t.string :collector_version, null: false
|
|
20
|
+
t.string :parser_version, null: false
|
|
21
|
+
t.string :source_ip
|
|
22
|
+
t.string :sample_uid, null: false
|
|
23
|
+
t.json :parse_errors, null: false, default: []
|
|
24
|
+
t.json :validation_errors, null: false, default: []
|
|
25
|
+
t.timestamps
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
add_index :tnw_metric_samples, %i[server_id collected_at]
|
|
29
|
+
add_index :tnw_metric_samples, %i[server_id sample_uid], unique: true
|
|
30
|
+
add_index :tnw_metric_samples, :collected_at
|
|
31
|
+
add_index :tnw_metric_samples, :received_at
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module Tnw
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
def create_initializer
|
|
9
|
+
template "tnw.rb", "config/initializers/tnw.rb"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def mount_engine
|
|
13
|
+
routes_path = destination_root_path.join("config/routes.rb")
|
|
14
|
+
return if routes_path.exist? && routes_path.read.include?("mount Tnw::Engine")
|
|
15
|
+
|
|
16
|
+
route 'mount Tnw::Engine => "/tnw"'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def copy_migrations
|
|
20
|
+
migration_sources.each_with_index do |source, index|
|
|
21
|
+
migration_name = File.basename(source).sub(/\A\d+_/, "")
|
|
22
|
+
next if migration_installed?(migration_name)
|
|
23
|
+
|
|
24
|
+
timestamp = (Time.now.utc + index.seconds).strftime("%Y%m%d%H%M%S")
|
|
25
|
+
copy_file source, "db/migrate/#{timestamp}_#{migration_name}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def migration_sources
|
|
32
|
+
Dir[Tnw::Engine.root.join("db/migrate/*.rb")].sort
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def migration_installed?(migration_name)
|
|
36
|
+
Dir[destination_root_path.join("db/migrate/*_#{migration_name}")].any?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def destination_root_path
|
|
40
|
+
Pathname(destination_root)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Tnw.configure do |config|
|
|
2
|
+
# Required outside development. Connect this to the host application's
|
|
3
|
+
# existing administrator authentication method.
|
|
4
|
+
# config.authenticate_with do |controller|
|
|
5
|
+
# controller.authenticate_admin!
|
|
6
|
+
# end
|
|
7
|
+
|
|
8
|
+
config.metrics_sample_interval = 5.seconds
|
|
9
|
+
config.metrics_retention = 7.days
|
|
10
|
+
config.metrics_ingestion_token = ENV["TNW_METRICS_INGESTION_TOKEN"]
|
|
11
|
+
config.metrics_accepted_clock_skew = 30.seconds
|
|
12
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
require "active_support/core_ext/numeric/time"
|
|
2
|
+
|
|
3
|
+
module Tnw
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_reader :metrics_sample_interval,
|
|
6
|
+
:metrics_retention,
|
|
7
|
+
:metrics_ingestion_token,
|
|
8
|
+
:metrics_accepted_clock_skew,
|
|
9
|
+
:authentication_handler,
|
|
10
|
+
:basic_auth_username,
|
|
11
|
+
:basic_auth_password
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@metrics_sample_interval = 5.seconds
|
|
15
|
+
@metrics_retention = 7.days
|
|
16
|
+
@metrics_ingestion_token = nil
|
|
17
|
+
@metrics_accepted_clock_skew = 30.seconds
|
|
18
|
+
@authentication_handler = nil
|
|
19
|
+
@basic_auth_username = nil
|
|
20
|
+
@basic_auth_password = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def metrics_sample_interval=(interval)
|
|
24
|
+
@metrics_sample_interval = positive_duration!(interval, :metrics_sample_interval)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def metrics_retention=(retention)
|
|
28
|
+
@metrics_retention = positive_duration!(retention, :metrics_retention)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def metrics_ingestion_token=(token)
|
|
32
|
+
unless token.nil? || (token.is_a?(String) && !token.strip.empty?)
|
|
33
|
+
raise ArgumentError, "metrics_ingestion_token must be a non-blank String or nil"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@metrics_ingestion_token = token
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def metrics_accepted_clock_skew=(clock_skew)
|
|
40
|
+
@metrics_accepted_clock_skew = positive_duration!(clock_skew, :metrics_accepted_clock_skew)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def authenticate_with(&block)
|
|
44
|
+
raise ArgumentError, "authenticate_with requires a block" unless block
|
|
45
|
+
|
|
46
|
+
@authentication_handler = block
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def basic_auth_username=(username)
|
|
50
|
+
@basic_auth_username = optional_non_blank_string!(username, :basic_auth_username)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def basic_auth_password=(password)
|
|
54
|
+
@basic_auth_password = optional_non_blank_string!(password, :basic_auth_password)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def basic_auth_configured?
|
|
58
|
+
basic_auth_username && basic_auth_password
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def inspect
|
|
62
|
+
token = metrics_ingestion_token.nil? ? nil : "[FILTERED]"
|
|
63
|
+
password = basic_auth_password.nil? ? nil : "[FILTERED]"
|
|
64
|
+
|
|
65
|
+
"#<#{self.class.name} " \
|
|
66
|
+
"metrics_sample_interval=#{metrics_sample_interval.inspect} " \
|
|
67
|
+
"metrics_retention=#{metrics_retention.inspect} " \
|
|
68
|
+
"metrics_ingestion_token=#{token.inspect} " \
|
|
69
|
+
"metrics_accepted_clock_skew=#{metrics_accepted_clock_skew.inspect} " \
|
|
70
|
+
"authentication_handler=#{authentication_handler.inspect} " \
|
|
71
|
+
"basic_auth_username=#{basic_auth_username.inspect} " \
|
|
72
|
+
"basic_auth_password=#{password.inspect}>"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def positive_duration!(value, name)
|
|
78
|
+
seconds = value.respond_to?(:in_seconds) ? value.in_seconds : value
|
|
79
|
+
|
|
80
|
+
unless seconds.is_a?(Numeric) && seconds.positive? && seconds.finite?
|
|
81
|
+
raise ArgumentError, "#{name} must be positive"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
value
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def optional_non_blank_string!(value, name)
|
|
88
|
+
unless value.nil? || (value.is_a?(String) && !value.strip.empty?)
|
|
89
|
+
raise ArgumentError, "#{name} must be a non-blank String or nil"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
value
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
data/lib/tnw/engine.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Tnw
|
|
2
|
+
module Metrics
|
|
3
|
+
module Parsers
|
|
4
|
+
class Df
|
|
5
|
+
BLOCK_BYTES = 1024
|
|
6
|
+
|
|
7
|
+
def self.parse(output, mount: "/")
|
|
8
|
+
rows = output.lines.drop(1).filter_map do |line|
|
|
9
|
+
fields = line.strip.split(/\s+/, 6)
|
|
10
|
+
fields if fields.length == 6
|
|
11
|
+
end
|
|
12
|
+
row = rows.find { |fields| fields.fetch(5) == mount }
|
|
13
|
+
raise ParseError, "df output is missing mount #{mount}" unless row
|
|
14
|
+
|
|
15
|
+
total = blocks_to_bytes(row.fetch(1))
|
|
16
|
+
used = blocks_to_bytes(row.fetch(2))
|
|
17
|
+
available = blocks_to_bytes(row.fetch(3))
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
total_bytes: total,
|
|
21
|
+
used_bytes: used,
|
|
22
|
+
available_bytes: available,
|
|
23
|
+
mount: row.fetch(5)
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.blocks_to_bytes(value)
|
|
28
|
+
Integer(value, 10) * BLOCK_BYTES
|
|
29
|
+
rescue ArgumentError
|
|
30
|
+
raise ParseError, "df output contains an invalid block count"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private_class_method :blocks_to_bytes
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Tnw
|
|
2
|
+
module Metrics
|
|
3
|
+
module Parsers
|
|
4
|
+
class ProcLoadavg
|
|
5
|
+
def self.parse(output)
|
|
6
|
+
values = output.split.first(3)
|
|
7
|
+
raise ParseError, "/proc/loadavg has too few values" if values.length < 3
|
|
8
|
+
|
|
9
|
+
{
|
|
10
|
+
one: Float(values.fetch(0)),
|
|
11
|
+
five: Float(values.fetch(1)),
|
|
12
|
+
fifteen: Float(values.fetch(2))
|
|
13
|
+
}
|
|
14
|
+
rescue ArgumentError
|
|
15
|
+
raise ParseError, "/proc/loadavg contains an invalid value"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Tnw
|
|
2
|
+
module Metrics
|
|
3
|
+
module Parsers
|
|
4
|
+
class ProcMeminfo
|
|
5
|
+
KIBIBYTE = 1024
|
|
6
|
+
|
|
7
|
+
def self.parse(output)
|
|
8
|
+
values = output.each_line.to_h do |line|
|
|
9
|
+
key, value = line.split(":", 2)
|
|
10
|
+
[ key, value.to_s.split.first ]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
total = kibibytes(values, "MemTotal")
|
|
14
|
+
available = kibibytes(values, "MemAvailable")
|
|
15
|
+
raise ParseError, "/proc/meminfo reports available memory above total memory" if available > total
|
|
16
|
+
|
|
17
|
+
{
|
|
18
|
+
total_bytes: total,
|
|
19
|
+
used_bytes: total - available,
|
|
20
|
+
available_bytes: available
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.kibibytes(values, key)
|
|
25
|
+
value = values[key]
|
|
26
|
+
raise ParseError, "/proc/meminfo is missing #{key}" unless value
|
|
27
|
+
|
|
28
|
+
Integer(value, 10) * KIBIBYTE
|
|
29
|
+
rescue ArgumentError
|
|
30
|
+
raise ParseError, "/proc/meminfo contains an invalid #{key} value"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private_class_method :kibibytes
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Tnw
|
|
2
|
+
module Metrics
|
|
3
|
+
module Parsers
|
|
4
|
+
class ProcStat
|
|
5
|
+
CPU_COUNTER_COUNT = 8
|
|
6
|
+
|
|
7
|
+
def self.parse(previous:, current:)
|
|
8
|
+
previous_counters = counters(previous)
|
|
9
|
+
current_counters = counters(current)
|
|
10
|
+
deltas = current_counters.zip(previous_counters).map { |after, before| after - before }
|
|
11
|
+
|
|
12
|
+
raise ParseError, "/proc/stat counters moved backwards" if deltas.any?(&:negative?)
|
|
13
|
+
|
|
14
|
+
total_delta = deltas.sum
|
|
15
|
+
raise ParseError, "/proc/stat contains no elapsed CPU time" unless total_delta.positive?
|
|
16
|
+
|
|
17
|
+
idle_delta = deltas.fetch(3) + deltas.fetch(4)
|
|
18
|
+
{ percent: ((total_delta - idle_delta) * 100.0 / total_delta).round(2) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.counters(output)
|
|
22
|
+
line = output.each_line.find { |candidate| candidate.start_with?("cpu ") }
|
|
23
|
+
raise ParseError, "/proc/stat is missing the aggregate cpu row" unless line
|
|
24
|
+
|
|
25
|
+
values = line.split.drop(1).first(CPU_COUNTER_COUNT)
|
|
26
|
+
raise ParseError, "/proc/stat has too few CPU counters" if values.length < CPU_COUNTER_COUNT
|
|
27
|
+
|
|
28
|
+
values.map { |value| Integer(value, 10) }
|
|
29
|
+
rescue ArgumentError
|
|
30
|
+
raise ParseError, "/proc/stat contains an invalid CPU counter"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private_class_method :counters
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Tnw
|
|
2
|
+
module Metrics
|
|
3
|
+
class ReplayGuard
|
|
4
|
+
def self.verify!(server:, sample_uid:)
|
|
5
|
+
if sample_uid.blank?
|
|
6
|
+
raise IngestionError.new(:missing_sample_uid, "Metric sample id is missing")
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
if server.metric_samples.exists?(sample_uid: sample_uid)
|
|
10
|
+
raise IngestionError.new(:replayed_sample, "Metric sample has already been accepted")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
true
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
module Tnw
|
|
4
|
+
module Metrics
|
|
5
|
+
class RequestVerifier
|
|
6
|
+
def self.signature(raw_body:, timestamp:, secret:)
|
|
7
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(raw_body:, timestamp:, signature:, now: Time.current, configuration: Tnw.configuration)
|
|
11
|
+
@raw_body = raw_body
|
|
12
|
+
@timestamp = timestamp
|
|
13
|
+
@signature = signature
|
|
14
|
+
@now = now
|
|
15
|
+
@configuration = configuration
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def verify!
|
|
19
|
+
secret = @configuration.metrics_ingestion_token
|
|
20
|
+
raise IngestionError.new(:not_configured, "Metric ingestion is not configured") unless secret
|
|
21
|
+
raise IngestionError.new(:missing_authentication, "Metric authentication is missing") if authentication_missing?
|
|
22
|
+
|
|
23
|
+
signed_at = parse_timestamp
|
|
24
|
+
verify_freshness!(signed_at)
|
|
25
|
+
verify_signature!(secret)
|
|
26
|
+
|
|
27
|
+
true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def authentication_missing?
|
|
33
|
+
@timestamp.blank? || @signature.blank?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def parse_timestamp
|
|
37
|
+
Time.at(Integer(@timestamp, 10)).utc
|
|
38
|
+
rescue ArgumentError, TypeError, RangeError
|
|
39
|
+
raise IngestionError.new(:invalid_timestamp, "Metric timestamp is invalid")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def verify_freshness!(signed_at)
|
|
43
|
+
skew = @configuration.metrics_accepted_clock_skew.in_seconds
|
|
44
|
+
return if (@now.to_f - signed_at.to_f).abs <= skew
|
|
45
|
+
|
|
46
|
+
raise IngestionError.new(:stale_timestamp, "Metric timestamp is outside the accepted clock skew")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def verify_signature!(secret)
|
|
50
|
+
expected = self.class.signature(raw_body: @raw_body, timestamp: @timestamp, secret: secret)
|
|
51
|
+
valid = @signature.bytesize == expected.bytesize &&
|
|
52
|
+
ActiveSupport::SecurityUtils.secure_compare(@signature, expected)
|
|
53
|
+
return if valid
|
|
54
|
+
|
|
55
|
+
raise IngestionError.new(:invalid_signature, "Metric signature is invalid")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/tnw/metrics.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require "tnw/metrics/parse_error"
|
|
2
|
+
require "tnw/metrics/ingestion_error"
|
|
3
|
+
require "tnw/metrics/request_verifier"
|
|
4
|
+
require "tnw/metrics/replay_guard"
|
|
5
|
+
require "tnw/metrics/parsers/proc_stat"
|
|
6
|
+
require "tnw/metrics/parsers/proc_meminfo"
|
|
7
|
+
require "tnw/metrics/parsers/df"
|
|
8
|
+
require "tnw/metrics/parsers/proc_loadavg"
|
|
9
|
+
|
|
10
|
+
module Tnw
|
|
11
|
+
module Metrics
|
|
12
|
+
PARSER_VERSION = "1"
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/tnw/version.rb
ADDED
data/lib/tnw.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "tnw/version"
|
|
2
|
+
require "tnw/configuration"
|
|
3
|
+
require "tnw/metrics"
|
|
4
|
+
require "tnw/engine"
|
|
5
|
+
|
|
6
|
+
module Tnw
|
|
7
|
+
class << self
|
|
8
|
+
def configuration
|
|
9
|
+
@configuration ||= Configuration.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def configure
|
|
13
|
+
yield(configuration)
|
|
14
|
+
configuration
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def reset_configuration!
|
|
18
|
+
@configuration = Configuration.new
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: tnw
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Catalin Ionescu
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.1'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '8.1'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9'
|
|
32
|
+
description: The Night Watch is a mountable Rails engine for inspecting and operating
|
|
33
|
+
a Kamal-backed Rails application.
|
|
34
|
+
executables: []
|
|
35
|
+
extensions: []
|
|
36
|
+
extra_rdoc_files: []
|
|
37
|
+
files:
|
|
38
|
+
- MIT-LICENSE
|
|
39
|
+
- README.md
|
|
40
|
+
- Rakefile
|
|
41
|
+
- app/assets/stylesheets/tnw/application.css
|
|
42
|
+
- app/controllers/tnw/application_controller.rb
|
|
43
|
+
- app/controllers/tnw/dashboard_controller.rb
|
|
44
|
+
- app/controllers/tnw/internal/metric_samples_controller.rb
|
|
45
|
+
- app/helpers/tnw/application_helper.rb
|
|
46
|
+
- app/jobs/tnw/application_job.rb
|
|
47
|
+
- app/jobs/tnw/metrics/retention_job.rb
|
|
48
|
+
- app/models/tnw/application_record.rb
|
|
49
|
+
- app/models/tnw/metric_sample.rb
|
|
50
|
+
- app/models/tnw/server.rb
|
|
51
|
+
- app/presenters/tnw/server_metric_status.rb
|
|
52
|
+
- app/services/tnw/metric_samples/ingest.rb
|
|
53
|
+
- app/views/layouts/tnw/application.html.erb
|
|
54
|
+
- app/views/tnw/dashboard/index.html.erb
|
|
55
|
+
- config/routes.rb
|
|
56
|
+
- db/migrate/20260711000001_create_tnw_servers.rb
|
|
57
|
+
- db/migrate/20260711000002_create_tnw_metric_samples.rb
|
|
58
|
+
- lib/generators/tnw/install/install_generator.rb
|
|
59
|
+
- lib/generators/tnw/install/templates/tnw.rb
|
|
60
|
+
- lib/tasks/tnw_tasks.rake
|
|
61
|
+
- lib/tnw.rb
|
|
62
|
+
- lib/tnw/configuration.rb
|
|
63
|
+
- lib/tnw/engine.rb
|
|
64
|
+
- lib/tnw/metrics.rb
|
|
65
|
+
- lib/tnw/metrics/ingestion_error.rb
|
|
66
|
+
- lib/tnw/metrics/parse_error.rb
|
|
67
|
+
- lib/tnw/metrics/parsers/df.rb
|
|
68
|
+
- lib/tnw/metrics/parsers/proc_loadavg.rb
|
|
69
|
+
- lib/tnw/metrics/parsers/proc_meminfo.rb
|
|
70
|
+
- lib/tnw/metrics/parsers/proc_stat.rb
|
|
71
|
+
- lib/tnw/metrics/replay_guard.rb
|
|
72
|
+
- lib/tnw/metrics/request_verifier.rb
|
|
73
|
+
- lib/tnw/version.rb
|
|
74
|
+
homepage: https://github.com/cionescu/tnw
|
|
75
|
+
licenses:
|
|
76
|
+
- MIT
|
|
77
|
+
metadata:
|
|
78
|
+
source_code_uri: https://github.com/cionescu/tnw
|
|
79
|
+
rubygems_mfa_required: 'true'
|
|
80
|
+
rdoc_options: []
|
|
81
|
+
require_paths:
|
|
82
|
+
- lib
|
|
83
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '3.2'
|
|
88
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: '0'
|
|
93
|
+
requirements: []
|
|
94
|
+
rubygems_version: 3.6.9
|
|
95
|
+
specification_version: 4
|
|
96
|
+
summary: A protected operational UI for Kamal-backed Rails applications.
|
|
97
|
+
test_files: []
|