Skip to content

Commit 6d50eb2

Browse files
authored
Merge branch 'main' into 408-introduce-primary-runtime-for-project
2 parents 2bd9e80 + 4f86352 commit 6d50eb2

34 files changed

+641
-3
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# frozen_string_literal: true
2+
3+
module Mutations
4+
module Namespaces
5+
module Projects
6+
class AssignRuntimes < BaseMutation
7+
description 'Assign runtimes to a project'
8+
9+
argument :namespace_project_id, Types::GlobalIdType[::NamespaceProject],
10+
description: 'ID of the project to assign runtimes to'
11+
argument :runtime_ids, [Types::GlobalIdType[::Runtime]], description: 'The new runtimes assigned to the project'
12+
13+
field :namespace_project, Types::NamespaceProjectType, null: true,
14+
description: 'The updated project with assigned runtimes'
15+
16+
def resolve(namespace_project_id:, runtime_ids:)
17+
namespace_project = SagittariusSchema.object_from_id(namespace_project_id)
18+
runtimes = runtime_ids.map { |runtime_id| SagittariusSchema.object_from_id(runtime_id) }
19+
20+
return { namespace_project: nil, errors: [create_message_error('Invalid project')] } if namespace_project.nil?
21+
return { namespace_project: nil, errors: [create_message_error('Invalid runtime')] } if runtimes.any?(&:nil?)
22+
23+
::Namespaces::Projects::AssignRuntimesService.new(
24+
current_authentication,
25+
namespace_project,
26+
runtimes
27+
).execute.to_mutation_response(success_key: :namespace_project)
28+
end
29+
end
30+
end
31+
end
32+
end

app/graphql/types/mutation_type.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class MutationType < Types::BaseObject
1010
mount_mutation Mutations::Namespaces::Members::AssignRoles
1111
mount_mutation Mutations::Namespaces::Members::Delete
1212
mount_mutation Mutations::Namespaces::Members::Invite
13+
mount_mutation Mutations::Namespaces::Projects::AssignRuntimes
1314
mount_mutation Mutations::Namespaces::Projects::Create
1415
mount_mutation Mutations::Namespaces::Projects::Update
1516
mount_mutation Mutations::Namespaces::Projects::Delete

app/graphql/types/namespace_project_type.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class NamespaceProjectType < Types::BaseObject
88

99
field :description, String, null: false, description: 'Description of the project'
1010
field :name, String, null: false, description: 'Name of the project'
11+
field :runtimes, Types::RuntimeType.connection_type, null: false, description: 'Runtimes assigned to this project'
1112

1213
field :namespace, Types::NamespaceType, null: false,
1314
description: 'The namespace where this project belongs to'
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# frozen_string_literal: true
2+
3+
module Types
4+
class RuntimeStatusType < BaseEnum
5+
description 'Represent all available types of statuses of a runtime'
6+
7+
value :CONNECTED, 'No problem with connection, everything works as expected', value: :connected
8+
value :DISCONNECTED, 'The runtime is disconnected, cause unknown', value: :disconnected
9+
end
10+
end

app/graphql/types/runtime_type.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ class RuntimeType < Types::BaseObject
1111
field :flow_types, Types::FlowTypeType.connection_type, null: false, description: 'FlowTypes of the runtime'
1212
field :name, String, null: false, description: 'The name for the runtime'
1313
field :namespace, Types::NamespaceType, null: true, description: 'The parent namespace for the runtime'
14+
field :projects, Types::NamespaceProjectType.connection_type, null: false,
15+
description: 'Projects associated with the runtime'
16+
field :status, Types::RuntimeStatusType, null: false, description: 'The status of the runtime'
17+
1418
field :token, String, null: true, description: 'Token belonging to the runtime, only present on creation'
1519

1620
id_field Runtime
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# frozen_string_literal: true
2+
3+
module GrpcStreamHandler
4+
include Code0::ZeroTrack::Loggable
5+
extend ActiveSupport::Concern
6+
7+
class_methods do
8+
def grpc_stream(method)
9+
define_method(method) do |_, call|
10+
current_runtime_id = Code0::ZeroTrack::Context.current[:runtime][:id]
11+
12+
create_enumerator(self.class, method, current_runtime_id, call.instance_variable_get(:@wrapped))
13+
end
14+
15+
define_method("send_#{method}") do |grpc_object, runtime_id|
16+
logger.info(message: 'Sending data', runtime_id: runtime_id, method: method)
17+
18+
encoded_data = self.class.encoders[method].call(grpc_object)
19+
encoded_data64 = Base64.encode64(encoded_data)
20+
21+
logger.info(message: 'Encoded data', runtime_id: runtime_id, method: method, encoded_data: encoded_data64)
22+
23+
ActiveRecord::Base.connection.raw_connection
24+
.exec("NOTIFY grpc_streams, '#{self.class},#{method},#{runtime_id},#{encoded_data64}'")
25+
end
26+
define_method("end_#{method}") do |runtime_id|
27+
ActiveRecord::Base.connection.raw_connection
28+
.exec("NOTIFY grpc_streams, '#{self.class},#{method},#{runtime_id},end'")
29+
end
30+
end
31+
end
32+
33+
def self.stop_listen!
34+
logger.info(message: 'Stopping listener')
35+
GrpcStreamHandler.exiting = true
36+
end
37+
38+
def self.listen!
39+
ActiveRecord::Base.with_connection do |ar_conn|
40+
conn = ar_conn.raw_connection
41+
conn.exec('LISTEN grpc_streams')
42+
43+
logger.info(message: 'Listening for notifications on grpc_streams channel')
44+
loop do
45+
break if GrpcStreamHandler.exiting
46+
47+
conn.wait_for_notify(1) do |_, _, payload|
48+
logger.info(message: 'Received notification', payload: payload)
49+
class_name, method_name, runtime_id, encoded_data64 = payload.split(',')
50+
51+
clazz = class_name.constantize
52+
method_name = method_name.to_sym
53+
54+
if encoded_data64 == 'end'
55+
decoded_data = :end
56+
else
57+
data = Base64.decode64(encoded_data64)
58+
decoded_data = clazz.decoders[method_name].call(data)
59+
end
60+
61+
queues = GrpcStreamHandler.yielders.dig(clazz, method_name, runtime_id.to_i)
62+
queues&.each do |queue|
63+
queue << decoded_data
64+
rescue StandardError => e
65+
logger.error(message: 'Error while yielding data', error: e.message)
66+
end
67+
end
68+
end
69+
conn.exec('UNLISTEN grpc_streams')
70+
logger.info(message: 'Stopped listening for notifications on grpc_streams channel')
71+
end
72+
end
73+
74+
def create_enumerator(clazz, method, runtime_id, _call)
75+
logger.debug(message: 'Creating enumerator', runtime_id: runtime_id, clazz: clazz, method: method)
76+
77+
queue = Queue.new
78+
79+
enumerator = Enumerator.new do |y|
80+
loop do
81+
item = queue.pop(timeout: 1)
82+
next if item.nil?
83+
break if item == :end
84+
85+
begin
86+
y << item
87+
rescue GRPC::Core::CallError
88+
logger.info(message: 'Stream was closed from client side (probably)')
89+
clazz.try("#{method}_died", runtime_id)
90+
91+
raise
92+
end
93+
end
94+
logger.info(message: 'Stream was closed from server side')
95+
clazz.try("#{method}_died", runtime_id)
96+
end
97+
98+
GrpcStreamHandler.yielders[clazz] ||= {}
99+
GrpcStreamHandler.yielders[clazz][method] ||= {}
100+
GrpcStreamHandler.yielders[clazz][method][runtime_id] ||= []
101+
102+
GrpcStreamHandler.yielders[clazz][method][runtime_id] << queue
103+
104+
clazz.try("#{method}_started", runtime_id)
105+
106+
enumerator
107+
end
108+
109+
mattr_accessor :yielders, :exiting
110+
GrpcStreamHandler.yielders = {}
111+
GrpcStreamHandler.exiting = false
112+
end

app/grpc/flow_handler.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
class FlowHandler < Tucana::Sagittarius::FlowService::Service
4+
include GrpcHandler
5+
include GrpcStreamHandler
6+
7+
grpc_stream :update
8+
9+
def self.update_started(runtime_id)
10+
runtime = Runtime.find(runtime_id)
11+
runtime.connected!
12+
runtime.save
13+
end
14+
15+
def self.update_died(runtime_id)
16+
runtime = Runtime.find(runtime_id)
17+
runtime.disconnected!
18+
runtime.save
19+
end
20+
21+
def self.encoders = { update: ->(grpc_object) { Tucana::Sagittarius::FlowResponse.encode(grpc_object) } }
22+
23+
def self.decoders = { update: ->(string) { Tucana::Sagittarius::FlowResponse.decode(string) } }
24+
end

app/models/audit_event.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class AuditEvent < ApplicationRecord
3131
user_identity_linked: 27,
3232
user_identity_unlinked: 28,
3333
attachment_updated: 29,
34+
project_runtimes_assigned: 30,
3435
}.with_indifferent_access
3536

3637
# rubocop:disable Lint/StructNewOverride

app/models/namespace_project.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ class NamespaceProject < ApplicationRecord
44
belongs_to :namespace, inverse_of: :projects
55
belongs_to :primary_runtime, class_name: 'Runtime', optional: true
66

7+
has_many :runtime_assignments, class_name: 'NamespaceProjectRuntimeAssignment', inverse_of: :namespace_project
8+
has_many :runtimes, through: :runtime_assignments, inverse_of: :projects
9+
710
has_many :role_assignments, class_name: 'NamespaceRoleProjectAssignment',
811
inverse_of: :project
912
has_many :assigned_roles, class_name: 'NamespaceRole', through: :role_assignments,
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# frozen_string_literal: true
2+
3+
class NamespaceProjectRuntimeAssignment < ApplicationRecord
4+
belongs_to :runtime, inverse_of: :project_assignments
5+
belongs_to :namespace_project, inverse_of: :runtime_assignments
6+
7+
validates :runtime, uniqueness: { scope: :namespace_project_id }
8+
9+
validate :validate_namespaces, if: :runtime_changed?
10+
validate :validate_namespaces, if: :namespace_project_changed?
11+
12+
private
13+
14+
def validate_namespaces
15+
return if runtime.namespace.nil?
16+
return if runtime.namespace == namespace_project.namespace
17+
18+
errors.add(:runtime, 'must belong to the same namespace as the project')
19+
end
20+
end

app/models/namespace_role_ability.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class NamespaceRoleAbility < ApplicationRecord
2424
delete_runtime: { db: 20, description: 'Allows to delete a runtime' },
2525
rotate_runtime_token: { db: 21, description: 'Allows to regenerate a runtime token' },
2626
assign_role_projects: { db: 22, description: 'Allows to change the assigned projects of a namespace role' },
27+
assign_project_runtimes: { db: 23, description: 'Allows to assign runtimes to a project in the namespace' },
2728
}.with_indifferent_access
2829
enum :ability, ABILITIES.transform_values { |v| v[:db] }, prefix: :can
2930

app/models/runtime.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ class Runtime < ApplicationRecord
77

88
token_attr :token, prefix: 's_rt_', length: 48
99

10+
enum :status, { disconnected: 0, connected: 1 }, default: :disconnected
11+
12+
has_many :project_assignments, class_name: 'NamespaceProjectRuntimeAssignment', inverse_of: :runtime
13+
has_many :projects, class_name: 'NamespaceProject', through: :project_assignments, inverse_of: :runtimes
14+
1015
has_many :data_types, inverse_of: :runtime
1116

1217
has_many :flow_types, inverse_of: :runtime

app/policies/namespace_project_policy.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class NamespaceProjectPolicy < BasePolicy
99

1010
rule { can_create_projects }.enable :read_namespace_project
1111

12+
customizable_permission :assign_project_runtimes
1213
customizable_permission :read_namespace_project
1314
customizable_permission :update_namespace_project
1415
customizable_permission :delete_namespace_project
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# frozen_string_literal: true
2+
3+
module Namespaces
4+
module Projects
5+
class AssignRuntimesService
6+
include Sagittarius::Database::Transactional
7+
8+
attr_reader :current_authentication, :namespace_project, :runtimes
9+
10+
def initialize(current_authentication, namespace_project, runtimes)
11+
@current_authentication = current_authentication
12+
@namespace_project = namespace_project
13+
@runtimes = runtimes
14+
end
15+
16+
def execute
17+
unless Ability.allowed?(current_authentication, :assign_project_runtimes, namespace_project)
18+
return ServiceResponse.error(message: 'Missing permission', payload: :missing_permission)
19+
end
20+
21+
transactional do |t|
22+
old_assignments_for_audit_event = namespace_project.runtime_assignments.map do |assignment|
23+
{ id: assignment.runtime.id }
24+
end
25+
26+
namespace_project.runtimes = runtimes
27+
unless namespace_project.save
28+
t.rollback_and_return! ServiceResponse.error(
29+
message: 'Failed to assign runtimes to project',
30+
payload: namespace_project.errors
31+
)
32+
end
33+
34+
AuditService.audit(
35+
:project_runtimes_assigned,
36+
author_id: current_authentication.user.id,
37+
entity: namespace_project,
38+
target: namespace_project,
39+
details: {
40+
new_runtimes: runtimes.map { |runtime| { id: runtime.id } },
41+
old_runtimes: old_assignments_for_audit_event,
42+
}
43+
)
44+
45+
ServiceResponse.success(message: 'Assigned runtimes to a project', payload: namespace_project)
46+
end
47+
end
48+
end
49+
end
50+
end

bin/grpc_server

100644100755
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33

44
require_relative '../config/environment'
55

6-
Sagittarius::Grpc::Launcher.new.run!
6+
Sagittarius::Grpc::Launcher.new.start_blocking

bin/test_grpc_client

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env ruby
2+
3+
require 'grpc'
4+
require 'tucana'
5+
6+
Tucana.load_protocol(:sagittarius)
7+
8+
def main
9+
token = ARGV.size > 0 ? ARGV[0] : 'no-token-set'
10+
hostname = ARGV.size > 1 ? ARGV[1] : 'localhost:50051'
11+
stub = Tucana::Sagittarius::FlowService::Stub.new(hostname, :this_channel_is_insecure)
12+
begin
13+
message = Tucana::Sagittarius::FlowLogonRequest.new
14+
15+
puts "Sending: #{message.inspect}"
16+
17+
stub.update(message, {
18+
metadata: {
19+
authorization: token
20+
}
21+
}).each do |response|
22+
puts "Received: #{response.inspect}"
23+
24+
puts "Simulating connection abort"
25+
exit
26+
end
27+
28+
puts "Done"
29+
30+
rescue GRPC::BadStatus => e
31+
abort "ERROR: #{e.message}"
32+
end
33+
end
34+
35+
main
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# frozen_string_literal: true
2+
3+
class AddStatusToRuntime < Code0::ZeroTrack::Database::Migration[1.0]
4+
def change
5+
add_column :runtimes, :status, :integer, default: 0, null: false
6+
end
7+
end
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# frozen_string_literal: true
2+
3+
class CreateNamespaceProjectRuntimeAssignments < Code0::ZeroTrack::Database::Migration[1.0]
4+
def change
5+
create_table :namespace_project_runtime_assignments do |t|
6+
t.references :runtime, null: false, foreign_key: { on_delete: :cascade }, index: false
7+
t.references :namespace_project, null: false, foreign_key: { on_delete: :cascade }, index: false
8+
9+
t.index %i[runtime_id namespace_project_id], unique: true
10+
11+
t.timestamps_with_timezone
12+
end
13+
end
14+
end

db/schema_migrations/20250518095627

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
1ea1a340d1ae5c747ca07b3b45afef0b458b157a7118277eaf44b74dc0c03b91

db/schema_migrations/20250523224333

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
4c06be8a4a4a60500cc1772d3c8066c809659ff4ff80441d0fd444c6c4bccf48

0 commit comments

Comments
 (0)