diff --git a/app/grpc/flow_handler.rb b/app/grpc/flow_handler.rb index 1ab0deb7..05f004fd 100644 --- a/app/grpc/flow_handler.rb +++ b/app/grpc/flow_handler.rb @@ -9,14 +9,12 @@ class FlowHandler < Tucana::Sagittarius::FlowService::Service def self.update_runtime(runtime) flows = [] - runtime.projects.each do |project| - project.flows.each do |flow| + runtime.project_assignments.compatible.each do |assignment| + assignment.project.flows.each do |flow| flows << flow.to_grpc end end - # TODO: Add check to check for primary runtime conflicts - send_update( Tucana::Sagittarius::FlowResponse.new( flows: Tucana::Shared::Flows.new( diff --git a/app/jobs/update_runtime_compatibility_job.rb b/app/jobs/update_runtime_compatibility_job.rb new file mode 100644 index 00000000..21c6c63f --- /dev/null +++ b/app/jobs/update_runtime_compatibility_job.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class UpdateRuntimeCompatibilityJob < ApplicationJob + def perform(conditions) + assignments = NamespaceProjectRuntimeAssignment.where(conditions) + + assignments.each do |assignment| + res = Runtimes::CheckRuntimeCompatibilityService.new(assignment.runtime, assignment.namespace_project).execute + + assignment.compatible = res.success? + assignment.save! + end + end +end diff --git a/app/models/namespace_project_runtime_assignment.rb b/app/models/namespace_project_runtime_assignment.rb index d3ac2444..12832568 100644 --- a/app/models/namespace_project_runtime_assignment.rb +++ b/app/models/namespace_project_runtime_assignment.rb @@ -9,6 +9,8 @@ class NamespaceProjectRuntimeAssignment < ApplicationRecord validate :validate_namespaces, if: :runtime_changed? validate :validate_namespaces, if: :namespace_project_changed? + scope :compatible, -> { where(compatible: true) } + private def validate_namespaces diff --git a/app/services/error_code.rb b/app/services/error_code.rb index 2ef87976..06510c10 100644 --- a/app/services/error_code.rb +++ b/app/services/error_code.rb @@ -72,6 +72,10 @@ def self.error_codes primary_level_not_found: { description: '', deprecation_reason: 'Outdated concept' }, secondary_level_not_found: { description: '', deprecation_reason: 'Outdated concept' }, tertiary_level_exceeds_parameters: { description: '', deprecation_reason: 'Outdated concept' }, + + missing_primary_runtime: { description: 'The project is missing a primary runtime' }, + missing_definition: { description: 'The primary runtime has more definitions than this one' }, + outdated_definition: { description: 'The primary runtime has a newer definition than this one' }, } end end diff --git a/app/services/namespaces/projects/assign_runtimes_service.rb b/app/services/namespaces/projects/assign_runtimes_service.rb index a37ec69e..918a424a 100644 --- a/app/services/namespaces/projects/assign_runtimes_service.rb +++ b/app/services/namespaces/projects/assign_runtimes_service.rb @@ -32,6 +32,8 @@ def execute ) end + UpdateRuntimeCompatibilityJob.perform_later({ namespace_project_id: namespace_project.id }) + AuditService.audit( :project_runtimes_assigned, author_id: current_authentication.user.id, diff --git a/app/services/runtimes/check_runtime_compatibility_service.rb b/app/services/runtimes/check_runtime_compatibility_service.rb new file mode 100644 index 00000000..1f7c0b85 --- /dev/null +++ b/app/services/runtimes/check_runtime_compatibility_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Runtimes + class CheckRuntimeCompatibilityService + attr_reader :runtime, :namespace_project + + def initialize(runtime, namespace_project) + @runtime = runtime + @namespace_project = namespace_project + end + + def execute + primary_runtime = namespace_project.primary_runtime + + if primary_runtime.nil? + return ServiceResponse.error(message: 'No primary runtime given', + error_code: :missing_primary_runtime) + end + + { DataType => :identifier, FlowType => :identifier, + RuntimeFunctionDefinition => :runtime_name }.each do |model, identifier_field| + res = check_versions(model, identifier_field) + return res if res.error? + end + ServiceResponse.success(message: 'Runtime is compatible', payload: runtime) + end + + def check_versions(model, identifier_field = :identifier) + to_check_types = model.where(runtime: runtime) + primary_types = model.where(runtime: namespace_project.primary_runtime) + + if to_check_types.size < primary_types.size + return ServiceResponse.error(message: "#{model} amount dont match", + error_code: :missing_definition) + end + + primary_types.each do |curr_type| + to_check = model.find_by(runtime: runtime, identifier_field => curr_type.send(identifier_field)) + if to_check.nil? + return ServiceResponse.error(message: "#{model} is not present in new runtime", + error_code: :missing_definition) + end + + result = compare_version(curr_type.parsed_version, to_check.parsed_version) + + unless result + return ServiceResponse.error(message: "#{model} is outdated", + error_code: :outdated_definition) + end + end + ServiceResponse.success + end + + # true: compatible + # false: not compatible + def compare_version(primary_version, to_check_version) + return false if primary_version.segments[0] != to_check_version.segments[0] + return false if primary_version.segments[1] > to_check_version.segments[1] + + true + end + end +end diff --git a/app/services/runtimes/data_types/update_service.rb b/app/services/runtimes/data_types/update_service.rb index 9257fae5..043f8532 100644 --- a/app/services/runtimes/data_types/update_service.rb +++ b/app/services/runtimes/data_types/update_service.rb @@ -24,6 +24,8 @@ def execute end end + UpdateRuntimeCompatibilityJob.perform_later({ runtime_id: current_runtime.id }) + ServiceResponse.success(message: 'Updated data types', payload: data_types) end end diff --git a/app/services/runtimes/flow_types/update_service.rb b/app/services/runtimes/flow_types/update_service.rb index 8f0b6838..34391057 100644 --- a/app/services/runtimes/flow_types/update_service.rb +++ b/app/services/runtimes/flow_types/update_service.rb @@ -24,6 +24,8 @@ def execute end end + UpdateRuntimeCompatibilityJob.perform_later({ runtime_id: current_runtime.id }) + ServiceResponse.success(message: 'Updated data types', payload: flow_types) end end diff --git a/app/services/runtimes/runtime_function_definitions/update_service.rb b/app/services/runtimes/runtime_function_definitions/update_service.rb index 00d8d3f6..47f68d18 100644 --- a/app/services/runtimes/runtime_function_definitions/update_service.rb +++ b/app/services/runtimes/runtime_function_definitions/update_service.rb @@ -25,6 +25,8 @@ def execute end end + UpdateRuntimeCompatibilityJob.perform_later({ runtime_id: current_runtime.id }) + ServiceResponse.success(message: 'Updated runtime function definition', payload: runtime_function_definitions) end end diff --git a/config/environments/test.rb b/config/environments/test.rb index 983be8e7..bffdd608 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -46,6 +46,9 @@ # ActionMailer::Base.deliveries array. config.action_mailer.delivery_method = :test + # Use test adapter for ActiveJob to avoid connection leaks from GoodJob + config.active_job.queue_adapter = :test + # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr diff --git a/db/migrate/20251109141754_add_is_compatible_to_primary_runtime.rb b/db/migrate/20251109141754_add_is_compatible_to_primary_runtime.rb new file mode 100644 index 00000000..04d79557 --- /dev/null +++ b/db/migrate/20251109141754_add_is_compatible_to_primary_runtime.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddIsCompatibleToPrimaryRuntime < Code0::ZeroTrack::Database::Migration[1.0] + def change + add_column :namespace_project_runtime_assignments, :compatible, :boolean, null: false, default: false + end +end diff --git a/db/schema_migrations/20251109141754 b/db/schema_migrations/20251109141754 new file mode 100644 index 00000000..1aac43df --- /dev/null +++ b/db/schema_migrations/20251109141754 @@ -0,0 +1 @@ +d8a8171eddb13a575d76518ba6092f9f96d3b1e238d04da86104efc117ffbf14 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 70da55b8..bd28c700 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -465,7 +465,8 @@ CREATE TABLE namespace_project_runtime_assignments ( runtime_id bigint NOT NULL, namespace_project_id bigint NOT NULL, created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL + updated_at timestamp with time zone NOT NULL, + compatible boolean DEFAULT false NOT NULL ); CREATE SEQUENCE namespace_project_runtime_assignments_id_seq diff --git a/docs/graphql/enum/errorcodeenum.md b/docs/graphql/enum/errorcodeenum.md index 6df06179..6f37393f 100644 --- a/docs/graphql/enum/errorcodeenum.md +++ b/docs/graphql/enum/errorcodeenum.md @@ -45,9 +45,11 @@ Represents the available error responses | `LOADING_IDENTITY_FAILED` | Failed to load user identity from external provider | | `MFA_FAILED` | Invalid MFA data provided | | `MFA_REQUIRED` | MFA is required | +| `MISSING_DEFINITION` | The primary runtime has more definitions than this one | | `MISSING_IDENTITY_DATA` | This external identity is missing data | | `MISSING_PARAMETER` | Not all required parameters are present | | `MISSING_PERMISSION` | The user is not permitted to perform this operation | +| `MISSING_PRIMARY_RUNTIME` | The project is missing a primary runtime | | `NAMESPACE_MEMBER_NOT_FOUND` | The namespace member with the given identifier was not found | | `NAMESPACE_NOT_FOUND` | The namespace with the given identifier was not found | | `NAMESPACE_PROJECT_NOT_FOUND` | The namespace project with the given identifier was not found | @@ -55,6 +57,7 @@ Represents the available error responses | `NO_FREE_LICENSE_SEATS` | There are no free license seats to complete this operation | | `NO_PRIMARY_RUNTIME` | The project does not have a primary runtime | | `ORGANIZATION_NOT_FOUND` | The organization with the given identifier was not found | +| `OUTDATED_DEFINITION` | The primary runtime has a newer definition than this one | | `PRIMARY_LEVEL_NOT_FOUND` | **Deprecated:** Outdated concept | | `PROJECT_NOT_FOUND` | The namespace project with the given identifier was not found | | `REGISTRATION_DISABLED` | Self-registration is disabled | diff --git a/spec/jobs/update_runtime_compatibility_job_spec.rb b/spec/jobs/update_runtime_compatibility_job_spec.rb new file mode 100644 index 00000000..c23f773d --- /dev/null +++ b/spec/jobs/update_runtime_compatibility_job_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe UpdateRuntimeCompatibilityJob do + include ActiveJob::TestHelper + + it 'calls the compatibility service for each assignment and updates compatible' do + assignment1 = create(:namespace_project_runtime_assignment, compatible: false) + assignment2 = create(:namespace_project_runtime_assignment, compatible: false) + + success_res = ServiceResponse.success + err_response = ServiceResponse.error(error_code: :outdated_definition, message: 'Runtime is outdated') + + service1 = instance_double(Runtimes::CheckRuntimeCompatibilityService, execute: success_res) + service2 = instance_double(Runtimes::CheckRuntimeCompatibilityService, execute: err_response) + + allow(Runtimes::CheckRuntimeCompatibilityService).to receive(:new) + .with(assignment1.runtime, assignment1.namespace_project).and_return(service1) + allow(Runtimes::CheckRuntimeCompatibilityService).to receive(:new) + .with(assignment2.runtime, assignment2.namespace_project).and_return(service2) + + conditions = { id: [assignment1.id, assignment2.id] } + + perform_enqueued_jobs do + described_class.perform_later(conditions) + end + + expect(assignment1.reload.compatible).to be true + expect(assignment2.reload.compatible).to be false + end +end diff --git a/spec/services/runtimes/check_runtime_compatibility_service_spec.rb b/spec/services/runtimes/check_runtime_compatibility_service_spec.rb new file mode 100644 index 00000000..85f943cd --- /dev/null +++ b/spec/services/runtimes/check_runtime_compatibility_service_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Runtimes::CheckRuntimeCompatibilityService do + subject(:service_response) do + described_class.new(runtime, project).execute + end + + let(:primary_runtime) { create(:runtime) } + let(:runtime) { create(:runtime) } + let(:project) { create(:namespace_project, primary_runtime: primary_runtime) } + + context 'when primary runtime is missing' do + let(:project) { create(:namespace_project) } + + it 'returns an error with :missing_primary_runtime payload' do + expect(service_response).to be_error + expect(service_response.payload[:error_code]).to eq(:missing_primary_runtime) + end + end + + context 'when a model has fewer types on the runtime than on the primary' do + before do + create(:data_type, runtime: primary_runtime) + end + + it 'returns missing_datatypes error' do + expect(service_response.error?).to be true + expect(service_response.payload[:error_code]).to eq(:missing_definition) + end + end + + context 'when secondary runtime has outdated definitions' do + before do + create(:data_type, runtime: primary_runtime, identifier: 'dt1', version: '1.3.0') + create(:data_type, runtime: runtime, identifier: 'dt1', version: '1.2.0') + end + + it 'returns outdated_data_type error' do + expect(service_response.error?).to be true + expect(service_response.payload[:error_code]).to eq(:outdated_definition) + end + end + + context 'when all models are compatible' do + before do + create(:data_type, runtime: primary_runtime, identifier: 'dt1', version: '1.3.0') + create(:data_type, runtime: runtime, identifier: 'dt1', version: '1.3.0') + create(:flow_type, runtime: primary_runtime, identifier: 'ft1', version: '2.1.0') + create(:flow_type, runtime: runtime, identifier: 'ft1', version: '2.2.0') + create(:runtime_function_definition, runtime_name: 'rfd1', runtime: primary_runtime, version: '3.0.0') + create(:runtime_function_definition, runtime_name: 'rfd1', runtime: runtime, version: '3.1.0') + end + + it 'returns success with the runtime as payload' do + expect(service_response).to be_success + expect(service_response.payload).to eq(runtime) + end + end +end