From 8d5ad15650c0a4d5379543b1f082f9426540402f Mon Sep 17 00:00:00 2001 From: Omar Nasser Date: Wed, 4 Jun 2025 23:06:25 +0300 Subject: [PATCH 1/2] Update restrictions_enforcer to return all possible errors gathering all possible errors --- .../restrictions_enforcer.rb | 555 ++++++++++-------- ee/lib/remote_development/messages.rb | 1 + ...nvalid-command-field-type-devfile.yaml.erb | 13 + ...valid-commands-field-type-devfile.yaml.erb | 11 + ...ent-attributes-field-type-devfile.yaml.erb | 10 + ...alid-component-field-type-devfile.yaml.erb | 4 + ...component-name-field-type-devfile.yaml.erb | 11 + ...lid-components-field-type-devfile.yaml.erb | 3 + ...alid-container-field-type-devfile.yaml.erb | 7 + ...alid-endpoints-field-type-devfile.yaml.erb | 15 + ...invalid-events-field-type-devfile.yaml.erb | 9 + ...e.invalid-multiple-errors-devfile.yaml.erb | 27 + ...oot-attributes-field-type-devfile.yaml.erb | 12 + ...schema-version-field-type-devfile.yaml.erb | 15 + ...alid-variables-field-type-devfile.yaml.erb | 9 + .../main_integration_spec.rb | 18 + .../restrictions_enforcer_spec.rb | 62 +- lib/gitlab/fp/message_support.rb | 9 +- locale/gitlab.pot | 44 +- 19 files changed, 569 insertions(+), 266 deletions(-) create mode 100644 ee/spec/fixtures/remote_development/example.invalid-command-field-type-devfile.yaml.erb create mode 100644 ee/spec/fixtures/remote_development/example.invalid-commands-field-type-devfile.yaml.erb create mode 100644 ee/spec/fixtures/remote_development/example.invalid-component-attributes-field-type-devfile.yaml.erb create mode 100644 ee/spec/fixtures/remote_development/example.invalid-component-field-type-devfile.yaml.erb create mode 100644 ee/spec/fixtures/remote_development/example.invalid-component-name-field-type-devfile.yaml.erb create mode 100644 ee/spec/fixtures/remote_development/example.invalid-components-field-type-devfile.yaml.erb create mode 100644 ee/spec/fixtures/remote_development/example.invalid-container-field-type-devfile.yaml.erb create mode 100644 ee/spec/fixtures/remote_development/example.invalid-endpoints-field-type-devfile.yaml.erb create mode 100644 ee/spec/fixtures/remote_development/example.invalid-events-field-type-devfile.yaml.erb create mode 100644 ee/spec/fixtures/remote_development/example.invalid-multiple-errors-devfile.yaml.erb create mode 100644 ee/spec/fixtures/remote_development/example.invalid-root-attributes-field-type-devfile.yaml.erb create mode 100644 ee/spec/fixtures/remote_development/example.invalid-schema-version-field-type-devfile.yaml.erb create mode 100644 ee/spec/fixtures/remote_development/example.invalid-variables-field-type-devfile.yaml.erb diff --git a/ee/lib/remote_development/devfile_operations/restrictions_enforcer.rb b/ee/lib/remote_development/devfile_operations/restrictions_enforcer.rb index 143a1f7d78d2d6..62c11d37de99ba 100644 --- a/ee/lib/remote_development/devfile_operations/restrictions_enforcer.rb +++ b/ee/lib/remote_development/devfile_operations/restrictions_enforcer.rb @@ -32,50 +32,64 @@ class RestrictionsEnforcer # Currently, we only support the default value `false` for the `hotReloadCapable` option SUPPORTED_HOT_RELOAD_VALUE = false - # @param [Hash] context + # @param [Hash] parent_context # @return [Gitlab::Fp::Result] - def self.enforce(context) - Gitlab::Fp::Result.ok(context) - .and_then(method(:validate_devfile_size)) - .and_then(method(:validate_schema_version)) - .and_then(method(:validate_parent)) - .and_then(method(:validate_projects)) - .and_then(method(:validate_root_attributes)) - .and_then(method(:validate_components)) - .and_then(method(:validate_containers)) - .and_then(method(:validate_endpoints)) - .and_then(method(:validate_commands)) - .and_then(method(:validate_events)) - .and_then(method(:validate_variables)) - end - - private + def self.enforce(parent_context) + context = { + # NOTE: `processed_devfile` is not available in the context until the devfile has been flattened. + # If the devfile is flattened, use `processed_devfile`. Else, use `devfile`. + devfile: parent_context[:processed_devfile] || parent_context[:devfile], + errors: [] + } - # @param [Hash] context - # @return [Hash] the `processed_devfile` out of the `context` if it exists, otherwise the `devfile` - def self.devfile_to_validate(context) - # NOTE: `processed_devfile` is not available in the context until the devfile has been flattened. - # If the devfile is flattened, use `processed_devfile`. Else, use `devfile`. - context[:processed_devfile] || context[:devfile] + initial_result = Gitlab::Fp::Result.ok(context) + + result = + initial_result + .and_then(method(:validate_devfile_size)) + .map(method(:validate_schema_version)) + .map(method(:validate_parent)) + .map(method(:validate_projects)) + .map(method(:validate_root_attributes)) + .map(method(:validate_components)) + .map(method(:validate_containers)) + .map(method(:validate_endpoints)) + .map(method(:validate_commands)) + .map(method(:validate_events)) + .map(method(:validate_variables)) + + case result + in { ok: { errors: [] } } + Gitlab::Fp::Result.ok(parent_context) + in { ok: { errors: errors } } + Gitlab::Fp::Result.err(DevfileRestrictionsFailed.new({ details: errors, context: parent_context })) + in { err: DevfileSizeLimitExceeded => message } + Gitlab::Fp::Result.err( + DevfileRestrictionsFailed.new({ details: message.content[:details], context: parent_context }) + ) + else + raise Gitlab::Fp::UnmatchedResultError.new(result: result) + end end # @param [Hash] context # @return [Gitlab::Fp::Result] def self.validate_devfile_size(context) - devfile = devfile_to_validate(context) + context => { devfile: Hash => devfile } # Calculate the size of the devfile by converting it to JSON devfile_json = devfile.to_json devfile_size_bytes = devfile_json.bytesize if devfile_size_bytes > MAX_DEVFILE_SIZE_BYTES - return err( - format( - _("Devfile size (%{current_size}) exceeds the maximum allowed size of %{max_size}"), - current_size: ActiveSupport::NumberHelper.number_to_human_size(devfile_size_bytes), - max_size: ActiveSupport::NumberHelper.number_to_human_size(MAX_DEVFILE_SIZE_BYTES) - ), - context + details = format(_("Devfile size (%{current_size}) exceeds the maximum allowed size of %{max_size}"), + current_size: ActiveSupport::NumberHelper.number_to_human_size(devfile_size_bytes), + max_size: ActiveSupport::NumberHelper.number_to_human_size(MAX_DEVFILE_SIZE_BYTES) + ) + return Gitlab::Fp::Result.err( + DevfileSizeLimitExceeded.new( + { details: details, context: context } + ) ) end @@ -83,15 +97,20 @@ def self.validate_devfile_size(context) end # @param [Hash] context - # @return [Gitlab::Fp::Result] + # @return [Hash] def self.validate_schema_version(context) - devfile = devfile_to_validate(context) + context => { devfile: Hash => devfile } + + devfile_schema_version_string = devfile.fetch(:schemaVersion, "") + + unless devfile_schema_version_string.is_a?(String) + return append_err(_("'schemaVersion' must be a String"), context) + end - devfile_schema_version_string = devfile.fetch(:schemaVersion) begin devfile_schema_version = Gem::Version.new(devfile_schema_version_string) rescue ArgumentError - return err( + append_err( format(_("Invalid 'schemaVersion' '%{schema_version}'"), schema_version: devfile_schema_version_string), context ) @@ -99,9 +118,8 @@ def self.validate_schema_version(context) minimum_schema_version = Gem::Version.new(REQUIRED_DEVFILE_SCHEMA_VERSION) unless devfile_schema_version == minimum_schema_version - return err( - format( - _("'schemaVersion' '%{given_version}' is not supported, it must be '%{required_version}'"), + append_err( + format(_("'schemaVersion' '%{given_version}' is not supported, it must be '%{required_version}'"), given_version: devfile_schema_version_string, required_version: REQUIRED_DEVFILE_SCHEMA_VERSION ), @@ -109,64 +127,73 @@ def self.validate_schema_version(context) ) end - Gitlab::Fp::Result.ok(context) + context end # @param [Hash] context - # @return [Gitlab::Fp::Result] + # @return [Hash] def self.validate_parent(context) - devfile = devfile_to_validate(context) + context => { devfile: Hash => devfile } + append_err(_("Inheriting from 'parent' is not yet supported"), context) if devfile.has_key?(:parent) - return err(format(_("Inheriting from 'parent' is not yet supported")), context) if devfile[:parent] - - Gitlab::Fp::Result.ok(context) + context end # @param [Hash] context - # @return [Gitlab::Fp::Result] + # @return [Hash] def self.validate_projects(context) - devfile = devfile_to_validate(context) - - return err(_("'starterProjects' is not yet supported"), context) if devfile[:starterProjects] - return err(_("'projects' is not yet supported"), context) if devfile[:projects] + context => { devfile: Hash => devfile } + append_err(_("'starterProjects' is not yet supported"), context) if devfile.has_key?(:starterProjects) + append_err(_("'projects' is not yet supported"), context) if devfile.has_key?(:projects) - Gitlab::Fp::Result.ok(context) + context end # @param [Hash] context - # @return [Gitlab::Fp::Result] + # @return [Hash] def self.validate_root_attributes(context) - devfile = devfile_to_validate(context) + context => { devfile: Hash => devfile } + root_attributes = devfile.fetch(:attributes, {}) - return err(_("Attribute 'pod-overrides' is not yet supported"), context) if devfile.dig(:attributes, - :"pod-overrides") + return append_err(_("'Attributes' must be a Hash"), context) unless root_attributes.is_a?(Hash) - Gitlab::Fp::Result.ok(context) + if devfile.dig(:attributes, :"pod-overrides") + append_err(_("Attribute 'pod-overrides' is not yet supported"), context) + end + + context end # @param [Hash] context - # @return [Gitlab::Fp::Result] + # @return [Hash] def self.validate_components(context) - devfile = devfile_to_validate(context) + context => { devfile: Hash => devfile } + + components = devfile.fetch(:components, []) + return append_err(_("'Components' must be an Array"), context) unless components.is_a?(Array) + return append_err(_("No components present in devfile"), context) if components.blank? - components = devfile[:components] + append_err(_("Each element in 'components' must be a Hash"), context) unless components.all?(Hash) - return err(_("No components present in devfile"), context) if components.blank? + injected_main_components = [] + + components.each do |component| + validate_component(context, component) + next unless component.is_a?(Hash) + next unless component.fetch(:attributes, {}).is_a?(Hash) - injected_main_components = components.select do |component| - component.dig(:attributes, MAIN_COMPONENT_INDICATOR_ATTRIBUTE.to_sym) + injected_main_components << component if component.dig(:attributes, MAIN_COMPONENT_INDICATOR_ATTRIBUTE.to_sym) end if injected_main_components.empty? - return err( + append_err( format(_("No component has '%{attribute}' attribute"), attribute: MAIN_COMPONENT_INDICATOR_ATTRIBUTE), context ) end if injected_main_components.length > 1 - # noinspection RailsParamDefResolve -- this pluck isn't from ActiveRecord, it's from ActiveSupport - return err( + append_err( format( _("Multiple components '%{name}' have '%{attribute}' attribute"), name: injected_main_components.pluck(:name), # rubocop:disable CodeReuse/ActiveRecord -- this pluck isn't from ActiveRecord, it's from ActiveSupport @@ -176,88 +203,67 @@ def self.validate_components(context) ) end - components_all_have_names = components.all? { |component| component[:name].present? } - return err(_("Components must have a 'name'"), context) unless components_all_have_names - - components.each do |component| - component_name = component.fetch(:name) - # Ensure no component name starts with restricted_prefix - if component_name.downcase.start_with?(RESTRICTED_PREFIX) - return err( - format( - _("Component name '%{component}' must not start with '%{prefix}'"), - component: component_name, - prefix: RESTRICTED_PREFIX - ), - context - ) - end - - UNSUPPORTED_COMPONENT_TYPES.each do |unsupported_component_type| - if component[unsupported_component_type] - return err( - format(_("Component type '%{type}' is not yet supported"), type: unsupported_component_type), - context - ) - end - end - - return err(_("Attribute 'container-overrides' is not yet supported"), context) if component.dig( - :attributes, :"container-overrides") - - return err(_("Attribute 'pod-overrides' is not yet supported"), context) if component.dig(:attributes, - :"pod-overrides") - end - - Gitlab::Fp::Result.ok(context) + context end # @param [Hash] context - # @return [Gitlab::Fp::Result] + # @return [Hash] def self.validate_containers(context) - devfile = devfile_to_validate(context) + context => { devfile: Hash => devfile } - components = devfile.fetch(:components) + components = devfile.fetch(:components, []) + + # There is no need to append an error here since it has already been done before this code is executed. + return context unless components.is_a?(Array) components.each do |component| - container = component[:container] - next unless container + # There is no need to append an error here since it has already been done before this code is executed. + return context unless component.is_a?(Hash) + + container = component.fetch(:container, {}) + component_name = component.fetch(:name, "") + + # There is no need to append an error here since it has already been done before this code is executed. + unless container.is_a?(Hash) + return append_err( + format(_("'container' in component '%{component}' must be a Hash"), component: component_name), context) + end if container[:dedicatedPod] - return err( - format( - _("Property 'dedicatedPod' of component '%{name}' is not yet supported"), - name: component.fetch(:name) - ), - context - ) + append_err(format(_("Property 'dedicatedPod' of component '%{component}' is not yet supported"), + component: component_name), context) end end - Gitlab::Fp::Result.ok(context) + context end # @param [Hash] context - # @return [Gitlab::Fp::Result] + # @return [Hash] def self.validate_endpoints(context) - devfile = devfile_to_validate(context) - - components = devfile.fetch(:components) + context => { devfile: Hash => devfile } - err_result = nil + components = devfile.fetch(:components, []) + # There is no need to append an error here since it has already been done before this code is executed. + return context unless components.is_a?(Array) components.each do |component| - next unless component.dig(:container, :endpoints) + # There is no need to append an error here since it has already been done before this code is executed. + return context unless component.is_a?(Hash) - container = component.fetch(:container) + container = component.fetch(:container, {}) + # There is no need to append an error here since it has already been done before this code is executed. + return context unless container.is_a?(Hash) - container.fetch(:endpoints).each do |endpoint| - endpoint_name = endpoint.fetch(:name) + endpoints = container.fetch(:endpoints, []) + return append_err(_("'Endpoints' must be an Array"), context) unless endpoints.is_a?(Array) + + endpoints.each do |endpoint| + endpoint_name = endpoint.fetch(:name, "") next unless endpoint_name.downcase.start_with?(RESTRICTED_PREFIX) - err_result = err( - format( - _("Endpoint name '%{endpoint}' of component '%{component}' must not start with '%{prefix}'"), + append_err( + format(_("Endpoint name '%{endpoint}' of component '%{component}' must not start with '%{prefix}'"), endpoint: endpoint_name, component: component.fetch(:name), prefix: RESTRICTED_PREFIX @@ -267,107 +273,30 @@ def self.validate_endpoints(context) end end - return err_result if err_result - - Gitlab::Fp::Result.ok(context) + context end # @param [Hash] context - # @return [Gitlab::Fp::Result] + # @return [Hash] def self.validate_commands(context) - devfile = devfile_to_validate(context) - - devfile.fetch(:commands, []).each do |command| - command_id = command.fetch(:id) - - # Check command_id for restricted prefix - error_result = validate_restricted_prefix(command_id, 'command_id', context) - return error_result if error_result + context => { devfile: Hash => devfile } - supported_command_type = SUPPORTED_COMMAND_TYPES.find { |type| command[type].present? } - - unless supported_command_type - return err( - format( - _("Command '%{command}' must have one of the supported command types: %{supported_types}"), - command: command_id, - supported_types: SUPPORTED_COMMAND_TYPES.join(", ") - ), - context - ) - end - - # Ensure no command is referring to a component with restricted_prefix - command_type = command[supported_command_type] - - # Check if component is present (required for both exec and apply) - unless command_type[:component].present? - return err( - format( - _("'%{type}' command '%{command}' must specify a 'component'"), - type: supported_command_type, - command: command_id - ), - context - ) - end - - # Check component name for restricted prefix - component_name = command_type.fetch(:component) - - error_result = validate_restricted_prefix(component_name, 'component_name', context, - { command: command_id }) - return error_result if error_result - - # Check label for restricted prefix - command_label = command_type.fetch(:label, "") - - error_result = validate_restricted_prefix(command_label, 'label', context, - { command: command_id }) - return error_result if command_label.present? && error_result - - # Type-specicific validations for `exec` commands - # Since we only support the exec command type for user defined poststart events - # We don't need to have validation for other command types - next unless supported_command_type == :exec - - exec_command = command_type - - # Validate that only the supported options are used - unsupported_options = exec_command.keys - SUPPORTED_EXEC_COMMAND_OPTIONS - if unsupported_options.any? - return err( - format( - _("Unsupported options '%{options}' for exec command '%{command}'. " \ - "Only '%{supported_options}' are supported."), - options: unsupported_options.join(", "), - command: command_id, - supported_options: SUPPORTED_EXEC_COMMAND_OPTIONS.join(", ") - ), - context - ) - end + commands = devfile.fetch(:commands, []) + return append_err(_("'Commands' must be an Array"), context) unless commands.is_a?(Array) - if exec_command.key?(:hotReloadCapable) && exec_command[:hotReloadCapable] != SUPPORTED_HOT_RELOAD_VALUE - return err( - format( - _("Property 'hotReloadCapable' for exec command '%{command}' must be false when specified"), - command: command_id - ), - context - ) - end + commands.each do |command| + validate_command(context, command) end - Gitlab::Fp::Result.ok(context) + context end # @param [String] value # @param [String] type # @param [Hash] context # @param [Hash] additional_params - # @return [Gitlab::Fp::Result.err] - def self.validate_restricted_prefix(value, type, context, additional_params = {}) + # @return [Hash] + def self.validate_command_restricted_prefix(value, type, context, additional_params = {}) return unless value.downcase.start_with?(RESTRICTED_PREFIX) error_messages = { @@ -392,30 +321,32 @@ def self.validate_restricted_prefix(value, type, context, additional_params = {} params[:command_label] = value end - err(format(message_template, params), context) + append_err(format(message_template, params), context) end # @param [Hash] context - # @return [Gitlab::Fp::Result] + # @return [Hash] def self.validate_events(context) - devfile = devfile_to_validate(context) + context => { devfile: Hash => devfile } commands = devfile.fetch(:commands, []) + events = devfile.fetch(:events, {}) + + return append_err(_("'Events' must be a Hash"), context) unless events.is_a?(Hash) - devfile.fetch(:events, {}).each do |event_type, event_type_events| + events.each do |event_type, event_type_events| # Ensure no event type other than "preStart" are allowed if SUPPORTED_EVENTS.exclude?(event_type) && event_type_events.present? err_msg = format(_("Event type '%{type}' is not yet supported"), type: event_type) # The entries for unsupported events may be defined, but they must be blank. - return err(err_msg, context) + append_err(err_msg, context) end # Ensure no event starts with restricted_prefix event_type_events.each do |command_name| if command_name.downcase.start_with?(RESTRICTED_PREFIX) - return err( - format( - _("Event '%{event}' of type '%{event_type}' must not start with '%{prefix}'"), + append_err( + format(_("Event '%{event}' of type '%{event_type}' must not start with '%{prefix}'"), event: command_name, event_type: event_type, prefix: RESTRICTED_PREFIX @@ -430,37 +361,39 @@ def self.validate_events(context) # Check if the referenced command is an exec command referenced_command = commands.find { |cmd| cmd[:id] == command_name } - unless referenced_command[:exec].present? - return err( - format( - _("PostStart event references command '%{command}' which is not an exec command. Only exec " \ - "commands are supported in postStart events"), - command: command_name - ), - context - ) - end + next if referenced_command[:exec].present? + + append_err( + format(_("PostStart event references command '%{command}' which is not an exec command. Only exec " \ + "commands are supported in postStart events"), + command: command_name + ), + context + ) end end - Gitlab::Fp::Result.ok(context) + context end # @param [Hash] context - # @return [Gitlab::Fp::Result] + # @return [Hash] def self.validate_variables(context) - devfile = devfile_to_validate(context) + context => { devfile: Hash => devfile } restricted_prefix_underscore = RESTRICTED_PREFIX.tr("-", "_") # Ensure no variable name starts with restricted_prefix - devfile.fetch(:variables, {}).each_key do |variable| + variables = devfile.fetch(:variables, {}) + + return append_err(_("'Variables' must be a Hash"), context) unless variables.is_a?(Hash) + + variables.each_key do |variable| [RESTRICTED_PREFIX, restricted_prefix_underscore].each do |prefix| next unless variable.downcase.start_with?(prefix) - return err( # rubocop:disable Cop/AvoidReturnFromBlocks -- We want to use a return here - it works fine, and the alternative is unnecessarily complex. - format( - _("Variable name '%{variable}' must not start with '%{prefix}'"), + append_err( + format(_("Variable name '%{variable}' must not start with '%{prefix}'"), variable: variable, prefix: prefix ), @@ -469,19 +402,153 @@ def self.validate_variables(context) end end - Gitlab::Fp::Result.ok(context) + context end - # @param [String] details # @param [Hash] context - # @return [Gitlab::Fp::Result] - def self.err(details, context) - Gitlab::Fp::Result.err(DevfileRestrictionsFailed.new({ details: details, context: context })) + # @param [Hash] component + # @return [Hash] + def self.validate_component(context, component) + return unless component.is_a?(Hash) + + append_err(_("A component must have a 'name'"), context) unless component.has_key?(:name) + + component_name = component.fetch(:name, "") + if component_name.is_a?(String) + # Ensure no component name starts with restricted_prefix + if component_name.downcase.start_with?(RESTRICTED_PREFIX) + append_err( + format(_("Component name '%{component}' must not start with '%{prefix}'"), + component: component_name, + prefix: RESTRICTED_PREFIX + ), + context + ) + end + else + append_err(_("'Component name' must be a String"), context) + end + + attributes = component.fetch(:attributes, {}) + + if attributes.is_a?(Hash) + if component.dig(:attributes, :"container-overrides") + append_err(_("Attribute 'container-overrides' is not yet supported"), context) + end + + if component.dig(:attributes, :"pod-overrides") + append_err(_("Attribute 'pod-overrides' is not yet supported"), context) + end + else + append_err( + format(_("'attributes' for component '%{component_name}' must be a Hash"), + component_name: component_name), context) + end + + # Ensure component type is supported + UNSUPPORTED_COMPONENT_TYPES.each do |unsupported_component_type| + if component[unsupported_component_type] # rubocop: disable Style/Next -- No need to change to next + append_err( + format(_("Component type '%{type}' is not yet supported"), type: unsupported_component_type), + context + ) + end + end + + context + end + + # @param [Hash] context + # @param [Hash] command + # @return [Hash] + def self.validate_command(context, command) + return append_err(_("'command' must be a Hash"), context) unless command.is_a?(Hash) + + command_id = command.fetch(:id, "") + + # Check command_id for restricted prefix + validate_command_restricted_prefix(command_id, 'command_id', context) + + supported_command_type = SUPPORTED_COMMAND_TYPES.find { |type| command[type].present? } + + unless supported_command_type + return append_err( + format(_("Command '%{command}' must have one of the supported command types: %{supported_types}"), + command: command_id, + supported_types: SUPPORTED_COMMAND_TYPES.join(", ") + ), + context + ) + end + + command_type = command[supported_command_type] + + unless command_type[:component].present? + append_err( + format(_("'%{type}' command '%{command}' must specify a 'component'"), + type: supported_command_type, + command: command_id + ), + context + ) + end + + # Check component name for restricted prefix + component_name = command_type.fetch(:component, "") + + validate_command_restricted_prefix(component_name, 'component_name', context, + { command: command_id }) + + # Check label for restricted prefix + command_label = command_type.fetch(:label, "") + + validate_command_restricted_prefix(command_label, 'label', context, + { command: command_id }) + + # Type-specific validations for `exec` commands + # Since we only support the exec command type for user defined poststart events + # We don't need to have validation for other command types + return unless supported_command_type == :exec + + exec_command = command_type + + # Validate that only the supported options are used + unsupported_options = exec_command.keys - SUPPORTED_EXEC_COMMAND_OPTIONS + if unsupported_options.any? + append_err( + format(_("Unsupported options '%{options}' for exec command '%{command}'. " \ + "Only '%{supported_options}' are supported."), + options: unsupported_options.join(", "), + command: command_id, + supported_options: SUPPORTED_EXEC_COMMAND_OPTIONS.join(", ") + ), + context + ) + end + + if exec_command.key?(:hotReloadCapable) && exec_command[:hotReloadCapable] != SUPPORTED_HOT_RELOAD_VALUE + append_err( + format(_("Property 'hotReloadCapable' for exec command '%{command}' must be false when specified"), + command: command_id + ), + context + ) + end end - private_class_method :devfile_to_validate, :validate_devfile_size, :validate_schema_version, :validate_parent, - :validate_projects, :validate_components, :validate_containers, - :validate_endpoints, :validate_commands, :validate_restricted_prefix, :validate_events, - :validate_variables, :err, :validate_root_attributes + + # @param [String] message + # @param [Hash] context + # @return [Hash] + def self.append_err(message, context) + context.fetch(:errors).append(message) + + context + end + + private_class_method :validate_devfile_size, :validate_schema_version, :validate_parent, + :validate_projects, :validate_root_attributes, :validate_components, :validate_containers, + :validate_endpoints, :validate_commands, :validate_command_restricted_prefix, :validate_events, + :validate_variables, :validate_component, :validate_command, :append_err end end end diff --git a/ee/lib/remote_development/messages.rb b/ee/lib/remote_development/messages.rb index e5040511365156..d0f4ab37ba3680 100644 --- a/ee/lib/remote_development/messages.rb +++ b/ee/lib/remote_development/messages.rb @@ -44,6 +44,7 @@ module Messages OrganizationClusterAgentMappingNotFound = Class.new(Gitlab::Fp::Message) # Devfile errors + DevfileSizeLimitExceeded = Class.new(Gitlab::Fp::Message) DevfileYamlParseFailed = Class.new(Gitlab::Fp::Message) DevfileRestrictionsFailed = Class.new(Gitlab::Fp::Message) DevfileFlattenFailed = Class.new(Gitlab::Fp::Message) diff --git a/ee/spec/fixtures/remote_development/example.invalid-command-field-type-devfile.yaml.erb b/ee/spec/fixtures/remote_development/example.invalid-command-field-type-devfile.yaml.erb new file mode 100644 index 00000000000000..4d18c2e476f4b5 --- /dev/null +++ b/ee/spec/fixtures/remote_development/example.invalid-command-field-type-devfile.yaml.erb @@ -0,0 +1,13 @@ +--- +schemaVersion: 2.2.0 +components: + - name: example + attributes: + gl/inject-editor: true + container: + image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo +commands: + - true + - false +events: {} +variables: {} diff --git a/ee/spec/fixtures/remote_development/example.invalid-commands-field-type-devfile.yaml.erb b/ee/spec/fixtures/remote_development/example.invalid-commands-field-type-devfile.yaml.erb new file mode 100644 index 00000000000000..a28eba93a247ca --- /dev/null +++ b/ee/spec/fixtures/remote_development/example.invalid-commands-field-type-devfile.yaml.erb @@ -0,0 +1,11 @@ +--- +schemaVersion: 2.2.0 +components: + - name: example + attributes: + gl/inject-editor: true + container: + image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo +commands: false +events: {} +variables: {} diff --git a/ee/spec/fixtures/remote_development/example.invalid-component-attributes-field-type-devfile.yaml.erb b/ee/spec/fixtures/remote_development/example.invalid-component-attributes-field-type-devfile.yaml.erb new file mode 100644 index 00000000000000..4412667fb7a489 --- /dev/null +++ b/ee/spec/fixtures/remote_development/example.invalid-component-attributes-field-type-devfile.yaml.erb @@ -0,0 +1,10 @@ +--- +schemaVersion: 2.2.0 +components: + - name: gl-example + attributes: 5 + container: + image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo +commands: [] +events: {} +variables: {} diff --git a/ee/spec/fixtures/remote_development/example.invalid-component-field-type-devfile.yaml.erb b/ee/spec/fixtures/remote_development/example.invalid-component-field-type-devfile.yaml.erb new file mode 100644 index 00000000000000..5a4b5998ff2b26 --- /dev/null +++ b/ee/spec/fixtures/remote_development/example.invalid-component-field-type-devfile.yaml.erb @@ -0,0 +1,4 @@ +--- +schemaVersion: 2.2.0 +components: + - gitlab diff --git a/ee/spec/fixtures/remote_development/example.invalid-component-name-field-type-devfile.yaml.erb b/ee/spec/fixtures/remote_development/example.invalid-component-name-field-type-devfile.yaml.erb new file mode 100644 index 00000000000000..f796d1faaf2aba --- /dev/null +++ b/ee/spec/fixtures/remote_development/example.invalid-component-name-field-type-devfile.yaml.erb @@ -0,0 +1,11 @@ +--- +schemaVersion: 2.2.0 +components: + - name: [1,2,3,4,5] + attributes: + gl/inject-editor: true + container: + image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo +commands: [] +events: {} +variables: {} diff --git a/ee/spec/fixtures/remote_development/example.invalid-components-field-type-devfile.yaml.erb b/ee/spec/fixtures/remote_development/example.invalid-components-field-type-devfile.yaml.erb new file mode 100644 index 00000000000000..9577b0faee6d7c --- /dev/null +++ b/ee/spec/fixtures/remote_development/example.invalid-components-field-type-devfile.yaml.erb @@ -0,0 +1,3 @@ +--- +schemaVersion: 2.2.0 +components: 200 diff --git a/ee/spec/fixtures/remote_development/example.invalid-container-field-type-devfile.yaml.erb b/ee/spec/fixtures/remote_development/example.invalid-container-field-type-devfile.yaml.erb new file mode 100644 index 00000000000000..7452622c60312d --- /dev/null +++ b/ee/spec/fixtures/remote_development/example.invalid-container-field-type-devfile.yaml.erb @@ -0,0 +1,7 @@ +--- +schemaVersion: 2.2.0 +components: + - name: gl-example + attributes: + gl/inject-editor: true + container: 50 diff --git a/ee/spec/fixtures/remote_development/example.invalid-endpoints-field-type-devfile.yaml.erb b/ee/spec/fixtures/remote_development/example.invalid-endpoints-field-type-devfile.yaml.erb new file mode 100644 index 00000000000000..569b0d235cbd7d --- /dev/null +++ b/ee/spec/fixtures/remote_development/example.invalid-endpoints-field-type-devfile.yaml.erb @@ -0,0 +1,15 @@ +--- +schemaVersion: 2.2.0 +components: + - name: example-valid-component + volume: + size: 42Gi + - name: example-invalid-second-component + attributes: + gl/inject-editor: true + container: + image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo + endpoints: true +commands: [] +events: {} +variables: {} diff --git a/ee/spec/fixtures/remote_development/example.invalid-events-field-type-devfile.yaml.erb b/ee/spec/fixtures/remote_development/example.invalid-events-field-type-devfile.yaml.erb new file mode 100644 index 00000000000000..c7383fc1de9f42 --- /dev/null +++ b/ee/spec/fixtures/remote_development/example.invalid-events-field-type-devfile.yaml.erb @@ -0,0 +1,9 @@ +--- +schemaVersion: "2.2.0" +components: + - name: development-environment + attributes: + gl/inject-editor: true + container: + image: "registry.gitlab.com/gitlab-org/gitlab-build-images/workspaces/ubuntu-24.04:20250414234733-golang-1.23-node-23.9-yarn-1.22-ruby-3.4.2-rust-1.85-php-8.4.5-java-21.0.6-python-3.13-docker-27.5.1@sha256:3b3fb1374084a20349019b88302fcc8ace1a3de5ab09465668d09f95a0eaa34b" +events: 50 diff --git a/ee/spec/fixtures/remote_development/example.invalid-multiple-errors-devfile.yaml.erb b/ee/spec/fixtures/remote_development/example.invalid-multiple-errors-devfile.yaml.erb new file mode 100644 index 00000000000000..bd8ba9193fa3e0 --- /dev/null +++ b/ee/spec/fixtures/remote_development/example.invalid-multiple-errors-devfile.yaml.erb @@ -0,0 +1,27 @@ +--- +schemaVersion: 2.2.0 +components: + - name: example-valid-component + volume: + size: 42Gi + - name: example-invalid-second-component + attributes: + gl/inject-editor: true + container: + image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo + # This example proves that all endpoints are processed, not just the first one + endpoints: + - name: example-valid-endpoint + targetPort: 3101 + protocol: https + exposure: none + - name: gl-example-invalid-second-endpoint + targetPort: 3102 + protocol: https + exposure: none +commands: [] +events: + preStart: + - example + - gl-example +variables: {} diff --git a/ee/spec/fixtures/remote_development/example.invalid-root-attributes-field-type-devfile.yaml.erb b/ee/spec/fixtures/remote_development/example.invalid-root-attributes-field-type-devfile.yaml.erb new file mode 100644 index 00000000000000..864ff49ff95642 --- /dev/null +++ b/ee/spec/fixtures/remote_development/example.invalid-root-attributes-field-type-devfile.yaml.erb @@ -0,0 +1,12 @@ +--- +schemaVersion: 2.2.0 +attributes: required +components: + - name: tooling-container + attributes: + gl/inject-editor: true + container: + image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo +commands: [] +events: {} +variables: {} diff --git a/ee/spec/fixtures/remote_development/example.invalid-schema-version-field-type-devfile.yaml.erb b/ee/spec/fixtures/remote_development/example.invalid-schema-version-field-type-devfile.yaml.erb new file mode 100644 index 00000000000000..c709bb7430d64c --- /dev/null +++ b/ee/spec/fixtures/remote_development/example.invalid-schema-version-field-type-devfile.yaml.erb @@ -0,0 +1,15 @@ +--- +schemaVersion: { 1.2, 2.0 } +components: + - name: tooling-container + attributes: + gl/inject-editor: true + container: + image: quay.io/mloriedo/universal-developer-image:ubi8-dw-demo + - name: database-container + container: + image: mysql + env: + - name: MYSQL_ROOT_PASSWORD + value: "my-secret-pw" + diff --git a/ee/spec/fixtures/remote_development/example.invalid-variables-field-type-devfile.yaml.erb b/ee/spec/fixtures/remote_development/example.invalid-variables-field-type-devfile.yaml.erb new file mode 100644 index 00000000000000..915409554bbdca --- /dev/null +++ b/ee/spec/fixtures/remote_development/example.invalid-variables-field-type-devfile.yaml.erb @@ -0,0 +1,9 @@ +--- +schemaVersion: "2.2.0" +components: + - name: development-environment + attributes: + gl/inject-editor: true + container: + image: "registry.gitlab.com/gitlab-org/gitlab-build-images/workspaces/ubuntu-24.04:20250414234733-golang-1.23-node-23.9-yarn-1.22-ruby-3.4.2-rust-1.85-php-8.4.5-java-21.0.6-python-3.13-docker-27.5.1@sha256:3b3fb1374084a20349019b88302fcc8ace1a3de5ab09465668d09f95a0eaa34b" +variables: 50 diff --git a/ee/spec/lib/remote_development/devfile_operations/main_integration_spec.rb b/ee/spec/lib/remote_development/devfile_operations/main_integration_spec.rb index d6e0389578cb15..5b047ff3321a71 100644 --- a/ee/spec/lib/remote_development/devfile_operations/main_integration_spec.rb +++ b/ee/spec/lib/remote_development/devfile_operations/main_integration_spec.rb @@ -72,4 +72,22 @@ it_behaves_like "tracks failed devfile validated event", "DevfileRestrictionsFailed" end + + context "when multiple params are invalid" do + let(:devfile_fixture_name) { "example.invalid-multiple-errors-devfile.yaml.erb" } + + it "returns failure" do + err1 = + "Endpoint name 'gl-example-invalid-second-endpoint' of component 'example-invalid-second-component' " \ + "must not start with 'gl-'" + err2 = "Event 'gl-example' of type 'preStart' must not start with 'gl-'" + expected_message = "Devfile restrictions failed: #{err1}, #{err2}" + + expect(response).to eq({ + status: :error, + message: expected_message, + reason: :bad_request + }) + end + end end diff --git a/ee/spec/lib/remote_development/devfile_operations/restrictions_enforcer_spec.rb b/ee/spec/lib/remote_development/devfile_operations/restrictions_enforcer_spec.rb index 6e665c33bad781..00079fba2f3632 100644 --- a/ee/spec/lib/remote_development/devfile_operations/restrictions_enforcer_spec.rb +++ b/ee/spec/lib/remote_development/devfile_operations/restrictions_enforcer_spec.rb @@ -39,12 +39,6 @@ with_them do it_behaves_like "an ok result" - - context "when both devfile and processed_devfile are passed in the context" do - let(:context) { { devfile: {}, processed_devfile: input_devfile } } - - it_behaves_like "an ok result" - end end end @@ -53,13 +47,28 @@ it "returns an err Result containing error details" do is_expected.to be_err_result do |message| expect(message).to be_a(RemoteDevelopment::Messages::DevfileRestrictionsFailed) - message.content => { details: String => error_details, context: Hash => actual_context } - expect(error_details).to eq(error_str) + message.content => { details: Array => error_details, context: Hash => actual_context } + expect(error_details).to include(error_str) expect(actual_context).to eq(context) end end end + # rubocop:disable Layout/LineLength -- we want single lines for RSpec::Parameterized::TableSyntax + context "when multiple errors are returned" do + using RSpec::Parameterized::TableSyntax + + where(:input_devfile_name, :error_str) do + "example.invalid-multiple-errors-devfile.yaml.erb" | "Endpoint name 'gl-example-invalid-second-endpoint' of component 'example-invalid-second-component' must not start with 'gl-'" + "example.invalid-multiple-errors-devfile.yaml.erb" | "Event 'gl-example' of type 'preStart' must not start with 'gl-'" + end + + with_them do + it_behaves_like "an err result" + end + end + # rubocop:enable Layout/LineLength + context "for single-array-entry devfiles" do using RSpec::Parameterized::TableSyntax @@ -67,7 +76,7 @@ where(:input_devfile_name, :error_str) do "example.invalid-attributes-tools-injector-absent-devfile.yaml.erb" | "No component has '#{main_component_indicator_attribute}' attribute" "example.invalid-attributes-tools-injector-multiple-devfile.yaml.erb" | "Multiple components '[\"tooling-container\", \"tooling-container-2\"]' have '#{main_component_indicator_attribute}' attribute" - "example.invalid-component-missing-name.yaml.erb" | "Components must have a 'name'" + "example.invalid-component-missing-name.yaml.erb" | "A component must have a 'name'" "example.invalid-command-missing-component-devfile.yaml.erb" | "'exec' command 'missing-component-command' must specify a 'component'" "example.invalid-components-attributes-container-overrides-devfile.yaml.erb" | "Attribute 'container-overrides' is not yet supported" "example.invalid-components-attributes-pod-overrides-devfile.yaml.erb" | "Attribute 'pod-overrides' is not yet supported" @@ -81,6 +90,18 @@ "example.invalid-restricted-prefix-command-name-devfile.yaml.erb" | "Command id 'gl-example' must not start with 'gl-'" "example.invalid-restricted-prefix-component-container-endpoint-name-devfile.yaml.erb" | "Endpoint name 'gl-example' of component 'example' must not start with 'gl-'" "example.invalid-restricted-prefix-component-name-devfile.yaml.erb" | "Component name 'gl-example' must not start with 'gl-'" + "example.invalid-component-name-field-type-devfile.yaml.erb" | "'Component name' must be a String" + "example.invalid-variables-field-type-devfile.yaml.erb" | "'Variables' must be a Hash" + "example.invalid-events-field-type-devfile.yaml.erb" | "'Events' must be a Hash" + "example.invalid-endpoints-field-type-devfile.yaml.erb" | "'Endpoints' must be an Array" + "example.invalid-commands-field-type-devfile.yaml.erb" | "'Commands' must be an Array" + "example.invalid-command-field-type-devfile.yaml.erb" | "'command' must be a Hash" + "example.invalid-container-field-type-devfile.yaml.erb" | "'container' in component 'gl-example' must be a Hash" + "example.invalid-component-field-type-devfile.yaml.erb" | "Each element in 'components' must be a Hash" + "example.invalid-components-field-type-devfile.yaml.erb" | "'Components' must be an Array" + "example.invalid-schema-version-field-type-devfile.yaml.erb" | "'schemaVersion' must be a String" + "example.invalid-root-attributes-field-type-devfile.yaml.erb" | "'Attributes' must be a Hash" + "example.invalid-component-attributes-field-type-devfile.yaml.erb" | "'attributes' for component 'gl-example' must be a Hash" "example.invalid-restricted-prefix-event-type-prestart-name-devfile.yaml.erb" | "Event 'gl-example' of type 'preStart' must not start with 'gl-'" "example.invalid-restricted-prefix-variable-name-devfile.yaml.erb" | "Variable name 'gl-example' must not start with 'gl-'" "example.invalid-restricted-prefix-command-apply-label-devfile.yaml.erb" | "Label 'gl-example' for command id 'example' must not start with 'gl-'" @@ -106,12 +127,6 @@ with_them do it_behaves_like "an err result" - - context "when both devfile and processed_devfile are passed in the context" do - let(:context) { { devfile: {}, processed_devfile: input_devfile } } - - it_behaves_like "an err result" - end end end @@ -154,11 +169,26 @@ is_expected.to be_err_result do |message| expect(message).to be_a(RemoteDevelopment::Messages::DevfileRestrictionsFailed) message.content => { details: String => error_details, context: Hash => actual_context } - expect(error_details).to match(/Devfile size .* exceeds the maximum allowed size/) + expect(error_details).to include(/Devfile size .* exceeds the maximum allowed size/) expect(actual_context).to eq(context) end end end end end + + context "when both devfile and processed_devfile are passed in the context" do + let(:context) do + { + # invalid, but ignored because processed_devfile is present + devfile: read_devfile("example.invalid-no-elements-devfile.yaml.erb"), + # valid, and takes precedence over devfile since it is present + processed_devfile: read_devfile("example.devfile.yaml.erb") + } + end + + it "gives precedence to the processed_devfile over the devfile" do + expect(result).to eq(Gitlab::Fp::Result.ok(context)) + end + end end diff --git a/lib/gitlab/fp/message_support.rb b/lib/gitlab/fp/message_support.rb index 20c8d16eac6b1b..8122d1a8ff5e74 100644 --- a/lib/gitlab/fp/message_support.rb +++ b/lib/gitlab/fp/message_support.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'active_model/errors' +require "active_model/errors" module Gitlab module Fp @@ -23,8 +23,13 @@ def generate_error_response_from_message(message:, reason:) nil in { details: String => error_details } error_details + in { details: Array => error_details } + # NOTE: This join string intentionally has two spaces, so that it is a unique pattern which can be + # split and parsed on the client if desired, to be presented in separate HTML elements. + # We may eventually return an array of error messages, but this is a workaround for now. + error_details.join(", ") in { errors: ActiveModel::Errors => errors } - errors.full_messages.join(', ') + errors.full_messages.join(", ") else raise "Unexpected message content, add a case to pattern match it and convert it to a String." end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 48e0ca5d3f049d..1d7ceb2efe341c 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1715,12 +1715,42 @@ msgstr "" msgid "'%{value}' days of inactivity must be greater than or equal to 90" msgstr "" +msgid "'Attributes' must be a Hash" +msgstr "" + +msgid "'Commands' must be an Array" +msgstr "" + +msgid "'Component name' must be a String" +msgstr "" + +msgid "'Components' must be an Array" +msgstr "" + msgid "'ConfluenceService|Your GitLab wiki is still available at %{link_start}%{wiki_url}%{link_end}. To re-enable the link to the GitLab wiki, disable this integration." msgstr "" +msgid "'Endpoints' must be an Array" +msgstr "" + +msgid "'Events' must be a Hash" +msgstr "" + +msgid "'Variables' must be a Hash" +msgstr "" + msgid "'allow: %{allow}' must be a string" msgstr "" +msgid "'attributes' for component '%{component_name}' must be a Hash" +msgstr "" + +msgid "'command' must be a Hash" +msgstr "" + +msgid "'container' in component '%{component}' must be a Hash" +msgstr "" + msgid "'cpu: %{cpu}' must be a string" msgstr "" @@ -1742,6 +1772,9 @@ msgstr "" msgid "'schemaVersion' '%{given_version}' is not supported, it must be '%{required_version}'" msgstr "" +msgid "'schemaVersion' must be a String" +msgstr "" + msgid "'starterProjects' is not yet supported" msgstr "" @@ -2166,6 +2199,9 @@ msgstr "" msgid "A basic template for developing Linux programs using Kotlin Native" msgstr "" +msgid "A component must have a 'name'" +msgstr "" + msgid "A confidential issue must have only confidential children. Make any child items confidential and try again." msgstr "" @@ -17080,9 +17116,6 @@ msgstr "" msgid "Component type '%{type}' is not yet supported" msgstr "" -msgid "Components must have a 'name'" -msgstr "" - msgid "CompromisedPasswordDetection|Change GitLab Password" msgstr "" @@ -24106,6 +24139,9 @@ msgstr "" msgid "ERROR: %{error}" msgstr "" +msgid "Each element in 'components' must be a Hash" +msgstr "" + msgid "Each project can also have an issue tracker and a wiki." msgstr "" @@ -50154,7 +50190,7 @@ msgstr "" msgid "Prompt users to upload SSH keys" msgstr "" -msgid "Property 'dedicatedPod' of component '%{name}' is not yet supported" +msgid "Property 'dedicatedPod' of component '%{component}' is not yet supported" msgstr "" msgid "Property 'hotReloadCapable' for exec command '%{command}' must be false when specified" -- GitLab From 730725b4b3d222f772f0ac5717790a431fd69301 Mon Sep 17 00:00:00 2001 From: Omar Nasser Date: Fri, 11 Jul 2025 02:42:15 +0300 Subject: [PATCH 2/2] Add test case to improve test coverage --- .../restrictions_enforcer_spec.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/ee/spec/lib/remote_development/devfile_operations/restrictions_enforcer_spec.rb b/ee/spec/lib/remote_development/devfile_operations/restrictions_enforcer_spec.rb index 00079fba2f3632..947dc2c3a2ff8b 100644 --- a/ee/spec/lib/remote_development/devfile_operations/restrictions_enforcer_spec.rb +++ b/ee/spec/lib/remote_development/devfile_operations/restrictions_enforcer_spec.rb @@ -191,4 +191,17 @@ expect(result).to eq(Gitlab::Fp::Result.ok(context)) end end + + context "when an unmatched error is returned" do + let(:context) { { devfile: {} } } + + it "raises an UnmatchedResultError" do + allow(described_class).to receive(:validate_devfile_size) + .and_return(Gitlab::Fp::Result.err(Class.new(Gitlab::Fp::Message).new({ + details: "UnmatchedErrorResult", context: context + }))) + + expect { described_class.enforce(context) }.to raise_error(Gitlab::Fp::UnmatchedResultError) + end + end end -- GitLab