18794: Add /metrics endpoint to RailsAPI.
authorTom Clegg <tom@curii.com>
Mon, 11 Apr 2022 18:35:03 +0000 (14:35 -0400)
committerTom Clegg <tom@curii.com>
Tue, 26 Apr 2022 15:19:20 +0000 (11:19 -0400)
Arvados-DCO-1.1-Signed-off-by: Tom Clegg <tom@curii.com>

services/api/app/controllers/arvados/v1/management_controller.rb [moved from services/api/app/controllers/arvados/v1/healthcheck_controller.rb with 54% similarity]
services/api/config/arvados_config.rb
services/api/config/routes.rb
services/api/test/functional/arvados/v1/healthcheck_controller_test.rb [deleted file]
services/api/test/functional/arvados/v1/management_controller_test.rb [new file with mode: 0644]
services/api/test/integration/errors_test.rb

similarity index 54%
rename from services/api/app/controllers/arvados/v1/healthcheck_controller.rb
rename to services/api/app/controllers/arvados/v1/management_controller.rb
index c56208207787526b8089239c50a31f34b1cafa23..55a00d346349cc53d951ad597044cd972852f1fb 100644 (file)
@@ -2,7 +2,7 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
 #
 # SPDX-License-Identifier: AGPL-3.0
 
-class Arvados::V1::HealthcheckController < ApplicationController
+class Arvados::V1::ManagementController < ApplicationController
   skip_before_action :catch_redirect_hint
   skip_before_action :find_objects_for_index
   skip_before_action :find_object_by_uuid
   skip_before_action :catch_redirect_hint
   skip_before_action :find_objects_for_index
   skip_before_action :find_object_by_uuid
@@ -29,8 +29,24 @@ class Arvados::V1::HealthcheckController < ApplicationController
     end
   end
 
     end
   end
 
-  def ping
-    resp = {"health" => "OK"}
-    send_json resp
+  def metrics
+    render content_type: 'text/plain', plain: <<~EOF
+# HELP arvados_config_load_timestamp_seconds Time when config file was loaded.
+# TYPE arvados_config_load_timestamp_seconds gauge
+arvados_config_load_timestamp_seconds{sha256="#{Rails.configuration.SourceSHA256}"} #{Rails.configuration.LoadTimestamp.to_f}
+# HELP arvados_config_source_timestamp_seconds Timestamp of config file when it was loaded.
+# TYPE arvados_config_source_timestamp_seconds gauge
+arvados_config_source_timestamp_seconds{sha256="#{Rails.configuration.SourceSHA256}"} #{Rails.configuration.SourceTimestamp.to_f}
+EOF
+  end
+
+  def health
+    case params[:check]
+    when 'ping'
+      resp = {"health" => "OK"}
+      send_json resp
+    else
+      send_json ({"errors" => "not found"}), status: 404
+    end
   end
 end
   end
 end
index 8a96c432a8df89873cccfaca7832dd9120ca9741..c0f7ee174fb65f8ef34d8502cc78d26e104f50ef 100644 (file)
@@ -30,6 +30,7 @@ end
 
 # Load the defaults, used by config:migrate and fallback loading
 # legacy application.yml
 
 # Load the defaults, used by config:migrate and fallback loading
 # legacy application.yml
+load_time = Time.now.utc
 defaultYAML, stderr, status = Open3.capture3("arvados-server", "config-dump", "-config=-", "-skip-legacy", stdin_data: "Clusters: {xxxxx: {}}")
 if !status.success?
   puts stderr
 defaultYAML, stderr, status = Open3.capture3("arvados-server", "config-dump", "-config=-", "-skip-legacy", stdin_data: "Clusters: {xxxxx: {}}")
 if !status.success?
   puts stderr
@@ -39,6 +40,8 @@ confs = YAML.load(defaultYAML, deserialize_symbols: false)
 clusterID, clusterConfig = confs["Clusters"].first
 $arvados_config_defaults = clusterConfig
 $arvados_config_defaults["ClusterID"] = clusterID
 clusterID, clusterConfig = confs["Clusters"].first
 $arvados_config_defaults = clusterConfig
 $arvados_config_defaults["ClusterID"] = clusterID
+$arvados_config_defaults["SourceTimestamp"] = Time.rfc3339(confs["SourceTimestamp"])
+$arvados_config_defaults["SourceSHA256"] = confs["SourceSHA256"]
 
 if ENV["ARVADOS_CONFIG"] == "none"
   # Don't load config. This magic value is set by packaging scripts so
 
 if ENV["ARVADOS_CONFIG"] == "none"
   # Don't load config. This magic value is set by packaging scripts so
@@ -54,6 +57,8 @@ else
       clusterID, clusterConfig = confs["Clusters"].first
       $arvados_config_global = clusterConfig
       $arvados_config_global["ClusterID"] = clusterID
       clusterID, clusterConfig = confs["Clusters"].first
       $arvados_config_global = clusterConfig
       $arvados_config_global["ClusterID"] = clusterID
+      $arvados_config_global["SourceTimestamp"] = Time.rfc3339(confs["SourceTimestamp"])
+      $arvados_config_global["SourceSHA256"] = confs["SourceSHA256"]
     else
       # config-dump failed, assume we will be loading from legacy
       # application.yml, initialize with defaults.
     else
       # config-dump failed, assume we will be loading from legacy
       # application.yml, initialize with defaults.
@@ -64,6 +69,7 @@ end
 
 # Now make a copy
 $arvados_config = $arvados_config_global.deep_dup
 
 # Now make a copy
 $arvados_config = $arvados_config_global.deep_dup
+$arvados_config["LoadTimestamp"] = load_time
 
 def arrayToHash cfg, k, v
   val = {}
 
 def arrayToHash cfg, k, v
   val = {}
index 98f5788d6505d3525115f3b3a8e5622b8a059937..9c7bfc3a7ac8ad1f8cd1ef54dd0dfc2c01f1065b 100644 (file)
@@ -112,7 +112,8 @@ Rails.application.routes.draw do
 
   match '/static/login_failure', to: 'static#login_failure', as: :login_failure, via: [:get, :post]
 
 
   match '/static/login_failure', to: 'static#login_failure', as: :login_failure, via: [:get, :post]
 
-  match '/_health/ping', to: 'arvados/v1/healthcheck#ping', via: [:get]
+  match '/_health/:check', to: 'arvados/v1/management#health', via: [:get]
+  match '/metrics', to: 'arvados/v1/management#metrics', via: [:get]
 
   # Send unroutable requests to an arbitrary controller
   # (ends up at ApplicationController#render_not_found)
 
   # Send unroutable requests to an arbitrary controller
   # (ends up at ApplicationController#render_not_found)
diff --git a/services/api/test/functional/arvados/v1/healthcheck_controller_test.rb b/services/api/test/functional/arvados/v1/healthcheck_controller_test.rb
deleted file mode 100644 (file)
index 76fdb04..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-# Copyright (C) The Arvados Authors. All rights reserved.
-#
-# SPDX-License-Identifier: AGPL-3.0
-
-require 'test_helper'
-
-class Arvados::V1::HealthcheckControllerTest < ActionController::TestCase
-  [
-    [false, nil, 404, 'disabled'],
-    [true, nil, 401, 'authorization required'],
-    [true, 'badformatwithnoBearer', 403, 'authorization error'],
-    [true, 'Bearer wrongtoken', 403, 'authorization error'],
-    [true, 'Bearer configuredmanagementtoken', 200, '{"health":"OK"}'],
-  ].each do |enabled, header, error_code, error_msg|
-    test "ping when #{if enabled then 'enabled' else 'disabled' end} with header '#{header}'" do
-      if enabled
-        Rails.configuration.ManagementToken = 'configuredmanagementtoken'
-      else
-        Rails.configuration.ManagementToken = ""
-      end
-
-      @request.headers['Authorization'] = header
-      get :ping
-      assert_response error_code
-
-      resp = JSON.parse(@response.body)
-      if error_code == 200
-        assert_equal(JSON.load('{"health":"OK"}'), resp)
-      else
-        assert_equal(error_msg, resp['errors'])
-      end
-    end
-  end
-end
diff --git a/services/api/test/functional/arvados/v1/management_controller_test.rb b/services/api/test/functional/arvados/v1/management_controller_test.rb
new file mode 100644 (file)
index 0000000..5b34f9f
--- /dev/null
@@ -0,0 +1,71 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+require 'test_helper'
+
+class Arvados::V1::ManagementControllerTest < ActionController::TestCase
+  [
+    [false, nil, 404, 'disabled'],
+    [true, nil, 401, 'authorization required'],
+    [true, 'badformatwithnoBearer', 403, 'authorization error'],
+    [true, 'Bearer wrongtoken', 403, 'authorization error'],
+    [true, 'Bearer configuredmanagementtoken', 200, '{"health":"OK"}'],
+  ].each do |enabled, header, error_code, error_msg|
+    test "_health/ping when #{if enabled then 'enabled' else 'disabled' end} with header '#{header}'" do
+      if enabled
+        Rails.configuration.ManagementToken = 'configuredmanagementtoken'
+      else
+        Rails.configuration.ManagementToken = ""
+      end
+
+      @request.headers['Authorization'] = header
+      get :health, params: {check: 'ping'}
+      assert_response error_code
+
+      resp = JSON.parse(@response.body)
+      if error_code == 200
+        assert_equal(JSON.load('{"health":"OK"}'), resp)
+      else
+        assert_equal(error_msg, resp['errors'])
+      end
+    end
+  end
+
+  test "metrics" do
+    mtime = File.mtime(ENV["ARVADOS_CONFIG"])
+    hash = Digest::SHA256.hexdigest(File.read(ENV["ARVADOS_CONFIG"]))
+    Rails.configuration.ManagementToken = "configuredmanagementtoken"
+    @request.headers['Authorization'] = "Bearer configuredmanagementtoken"
+    get :metrics
+    assert_response :success
+    assert_equal 'text/plain', @response.content_type
+
+    assert_match /\narvados_config_source_timestamp_seconds{sha256="#{hash}"} #{Regexp.escape mtime.utc.to_f.to_s}\n/, @response.body
+
+    # Expect mtime < loadtime < now
+    m = @response.body.match(/\narvados_config_load_timestamp_seconds{sha256="#{hash}"} (.*?)\n/)
+    assert_operator m[1].to_f, :>, mtime.utc.to_f
+    assert_operator m[1].to_f, :<, Time.now.utc.to_f
+  end
+
+  test "metrics disabled" do
+    Rails.configuration.ManagementToken = ""
+    @request.headers['Authorization'] = "Bearer configuredmanagementtoken"
+    get :metrics
+    assert_response 404
+  end
+
+  test "metrics bad token" do
+    Rails.configuration.ManagementToken = "configuredmanagementtoken"
+    @request.headers['Authorization'] = "Bearer asdf"
+    get :metrics
+    assert_response 403
+  end
+
+  test "metrics unauthorized" do
+    Rails.configuration.ManagementToken = "configuredmanagementtoken"
+    get :metrics
+    assert_response 401
+  end
+end
index a2a1545cee93d7ffcdd5a63073881abc960caa90..a5359278e52cd68b9ba9ff148fdc89c035a6ed38 100644 (file)
@@ -24,7 +24,7 @@ class ErrorsTest < ActionDispatch::IntegrationTest
       # Generally, new routes should appear under /arvados/v1/. If
       # they appear elsewhere, that might have been caused by default
       # rails generator behavior that we don't want.
       # Generally, new routes should appear under /arvados/v1/. If
       # they appear elsewhere, that might have been caused by default
       # rails generator behavior that we don't want.
-      assert_match(/^\/(|\*a|arvados\/v1\/.*|auth\/.*|login|logout|database\/reset|discovery\/.*|static\/.*|sys\/trash_sweep|themes\/.*|assets|_health\/.*)(\(\.:format\))?$/,
+      assert_match(/^\/(|\*a|arvados\/v1\/.*|auth\/.*|login|logout|database\/reset|discovery\/.*|static\/.*|sys\/trash_sweep|themes\/.*|assets|_health|metrics\/.*)(\(\.:format\))?$/,
                    route.path.spec.to_s,
                    "Unexpected new route: #{route.path.spec}")
     end
                    route.path.spec.to_s,
                    "Unexpected new route: #{route.path.spec}")
     end