From 2d5332420e7ecd79ac109e221aa05190afd04160 Mon Sep 17 00:00:00 2001
From: Tiger <twatson@gitlab.com>
Date: Fri, 4 Jun 2021 14:08:25 +1000
Subject: [PATCH] Add gRPC client for KAS

https://gitlab.com/gitlab-org/gitlab/-/merge_requests/62646
---
 Gemfile                            |  3 ++
 Gemfile.lock                       |  3 ++
 lib/gitlab/kas.rb                  |  7 +++
 lib/gitlab/kas/client.rb           | 75 ++++++++++++++++++++++++++
 spec/lib/gitlab/kas/client_spec.rb | 84 ++++++++++++++++++++++++++++++
 spec/lib/gitlab/kas_spec.rb        |  6 +++
 6 files changed, 178 insertions(+)
 create mode 100644 lib/gitlab/kas/client.rb
 create mode 100644 spec/lib/gitlab/kas/client_spec.rb

diff --git a/Gemfile b/Gemfile
index d99cd5970e4d9..c8f3d5c685545 100644
--- a/Gemfile
+++ b/Gemfile
@@ -482,6 +482,9 @@ gem 'spamcheck', '~> 0.1.0'
 # Gitaly GRPC protocol definitions
 gem 'gitaly', '~> 13.12.0.pre.rc1'
 
+# KAS GRPC protocol definitions
+gem 'kas-grpc', '~> 0.0.2'
+
 gem 'grpc', '~> 1.30.2'
 
 gem 'google-protobuf', '~> 3.17.1'
diff --git a/Gemfile.lock b/Gemfile.lock
index a1fd31680a16a..fc25767282223 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -682,6 +682,8 @@ GEM
       activerecord
       kaminari-core (= 1.2.1)
     kaminari-core (1.2.1)
+    kas-grpc (0.0.2)
+      grpc (~> 1.0)
     knapsack (1.21.1)
       rake
     kramdown (2.3.1)
@@ -1520,6 +1522,7 @@ DEPENDENCIES
   json_schemer (~> 0.2.12)
   jwt (~> 2.1.0)
   kaminari (~> 1.0)
+  kas-grpc (~> 0.0.2)
   knapsack (~> 1.21.1)
   kramdown (~> 2.3.1)
   kubeclient (~> 4.9.1)
diff --git a/lib/gitlab/kas.rb b/lib/gitlab/kas.rb
index 7b2c792ebca7a..a4663314b3b8c 100644
--- a/lib/gitlab/kas.rb
+++ b/lib/gitlab/kas.rb
@@ -45,6 +45,13 @@ def external_url
         Gitlab.config.gitlab_kas.external_url
       end
 
+      # Return GitLab KAS internal_url
+      #
+      # @return [String] internal_url
+      def internal_url
+        Gitlab.config.gitlab_kas.internal_url
+      end
+
       # Return whether GitLab KAS is enabled
       #
       # @return [Boolean] external_url
diff --git a/lib/gitlab/kas/client.rb b/lib/gitlab/kas/client.rb
new file mode 100644
index 0000000000000..6675903e69241
--- /dev/null
+++ b/lib/gitlab/kas/client.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Kas
+    class Client
+      TIMEOUT = 2.seconds.freeze
+      JWT_AUDIENCE = 'gitlab-kas'
+
+      STUB_CLASSES = {
+        configuration_project: Gitlab::Agent::ConfigurationProject::Rpc::ConfigurationProject::Stub
+      }.freeze
+
+      ConfigurationError = Class.new(StandardError)
+
+      def initialize
+        raise ConfigurationError, 'GitLab KAS is not enabled' unless Gitlab::Kas.enabled?
+        raise ConfigurationError, 'KAS internal URL is not configured' unless Gitlab::Kas.internal_url.present?
+      end
+
+      def list_agent_config_files(project:)
+        request = Gitlab::Agent::ConfigurationProject::Rpc::ListAgentConfigFilesRequest.new(
+          repository: repository(project),
+          gitaly_address: gitaly_address(project)
+        )
+
+        stub_for(:configuration_project)
+          .list_agent_config_files(request, metadata: metadata)
+          .config_files
+          .to_a
+      end
+
+      private
+
+      def stub_for(service)
+        @stubs ||= {}
+        @stubs[service] ||= STUB_CLASSES.fetch(service).new(kas_endpoint_url, credentials, timeout: TIMEOUT)
+      end
+
+      def repository(project)
+        gitaly_repository = project.repository.gitaly_repository
+
+        Gitlab::Agent::Modserver::Repository.new(gitaly_repository.to_h)
+      end
+
+      def gitaly_address(project)
+        connection_data = Gitlab::GitalyClient.connection_data(project.repository_storage)
+
+        Gitlab::Agent::Modserver::GitalyAddress.new(connection_data)
+      end
+
+      def kas_endpoint_url
+        Gitlab::Kas.internal_url.delete_prefix('grpc://')
+      end
+
+      def credentials
+        if Rails.env.test? || Rails.env.development?
+          :this_channel_is_insecure
+        else
+          GRPC::Core::ChannelCredentials.new
+        end
+      end
+
+      def metadata
+        { 'authorization' => "bearer #{token}" }
+      end
+
+      def token
+        JSONWebToken::HMACToken.new(Gitlab::Kas.secret).tap do |token|
+          token.issuer = Settings.gitlab.host
+          token.audience = JWT_AUDIENCE
+        end.encoded
+      end
+    end
+  end
+end
diff --git a/spec/lib/gitlab/kas/client_spec.rb b/spec/lib/gitlab/kas/client_spec.rb
new file mode 100644
index 0000000000000..7bf2d30ca48b9
--- /dev/null
+++ b/spec/lib/gitlab/kas/client_spec.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Kas::Client do
+  let_it_be(:project) { create(:project) }
+
+  describe '#initialize' do
+    context 'kas is not enabled' do
+      before do
+        allow(Gitlab::Kas).to receive(:enabled?).and_return(false)
+      end
+
+      it 'raises a configuration error' do
+        expect { described_class.new }.to raise_error(described_class::ConfigurationError, 'GitLab KAS is not enabled')
+      end
+    end
+
+    context 'internal url is not set' do
+      before do
+        allow(Gitlab::Kas).to receive(:enabled?).and_return(true)
+        allow(Gitlab::Kas).to receive(:internal_url).and_return(nil)
+      end
+
+      it 'raises a configuration error' do
+        expect { described_class.new }.to raise_error(described_class::ConfigurationError, 'KAS internal URL is not configured')
+      end
+    end
+  end
+
+  describe 'gRPC calls' do
+    let(:token) { instance_double(JSONWebToken::HMACToken, encoded: 'test-token') }
+
+    before do
+      allow(Gitlab::Kas).to receive(:enabled?).and_return(true)
+      allow(Gitlab::Kas).to receive(:internal_url).and_return('grpc://example.kas.internal')
+
+      expect(JSONWebToken::HMACToken).to receive(:new)
+        .with(Gitlab::Kas.secret)
+        .and_return(token)
+
+      expect(token).to receive(:issuer=).with(Settings.gitlab.host)
+      expect(token).to receive(:audience=).with(described_class::JWT_AUDIENCE)
+    end
+
+    describe '#list_agent_config_files' do
+      let(:stub) { instance_double(Gitlab::Agent::ConfigurationProject::Rpc::ConfigurationProject::Stub) }
+
+      let(:request) { instance_double(Gitlab::Agent::ConfigurationProject::Rpc::ListAgentConfigFilesRequest) }
+      let(:response) { double(Gitlab::Agent::ConfigurationProject::Rpc::ListAgentConfigFilesResponse, config_files: agent_configurations) }
+
+      let(:repository) { instance_double(Gitlab::Agent::Modserver::Repository) }
+      let(:gitaly_address) { instance_double(Gitlab::Agent::Modserver::GitalyAddress) }
+
+      let(:agent_configurations) { [double] }
+
+      subject { described_class.new.list_agent_config_files(project: project) }
+
+      before do
+        expect(Gitlab::Agent::ConfigurationProject::Rpc::ConfigurationProject::Stub).to receive(:new)
+          .with('example.kas.internal', :this_channel_is_insecure, timeout: described_class::TIMEOUT)
+          .and_return(stub)
+
+        expect(Gitlab::Agent::Modserver::Repository).to receive(:new)
+          .with(project.repository.gitaly_repository.to_h)
+          .and_return(repository)
+
+        expect(Gitlab::Agent::Modserver::GitalyAddress).to receive(:new)
+          .with(Gitlab::GitalyClient.connection_data(project.repository_storage))
+          .and_return(gitaly_address)
+
+        expect(Gitlab::Agent::ConfigurationProject::Rpc::ListAgentConfigFilesRequest).to receive(:new)
+          .with(repository: repository, gitaly_address: gitaly_address)
+          .and_return(request)
+
+        expect(stub).to receive(:list_agent_config_files)
+          .with(request, metadata: { 'authorization' => 'bearer test-token' })
+          .and_return(response)
+      end
+
+      it { expect(subject).to eq(agent_configurations) }
+    end
+  end
+end
diff --git a/spec/lib/gitlab/kas_spec.rb b/spec/lib/gitlab/kas_spec.rb
index e323f76b42e8a..c9d40f785b82d 100644
--- a/spec/lib/gitlab/kas_spec.rb
+++ b/spec/lib/gitlab/kas_spec.rb
@@ -65,6 +65,12 @@
     end
   end
 
+  describe '.internal_url' do
+    it 'returns gitlab_kas internal_url config' do
+      expect(described_class.internal_url).to eq(Gitlab.config.gitlab_kas.internal_url)
+    end
+  end
+
   describe '.version' do
     it 'returns gitlab_kas version config' do
       version_file = Rails.root.join(described_class::VERSION_FILE)
-- 
GitLab