diff --git a/.gitignore b/.gitignore index bda9611..55ee32a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ node_modules package-lock.json Gemfile.lock coverage +tmp/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..c1a6f66 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "es5" +} diff --git a/.rubocop.yml b/.rubocop.yml index d594d81..a0dc306 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,38 +1,31 @@ -require: - - rubocop-performance - - rubocop-rails - - rubocop-minitest - -AllCops: - TargetRubyVersion: 2.5 - TargetRailsVersion: 6.0 - Exclude: - - 'node_modules/**/*' +inherit_gem: + theforeman-rubocop: + - strictest.yml + +inherit_mode: + merge: + - Exclude Layout/ArgumentAlignment: EnforcedStyle: with_fixed_indentation IndentationWidth: 2 -Layout/EmptyLineAfterGuardClause: +Gemspec/RequiredRubyVersion: Enabled: false -Layout/LineLength: - Enabled: 111 # TODO: discuss and set this - -Rails: - Enabled: true - -Style/Alias: - EnforcedStyle: prefer_alias_method - -# Don't enforce documentation -Style/Documentation: +Metrics/MethodLength: Enabled: false -# Don't enforce frozen string literals -Style/FrozenStringLiteralComment: +Metrics/PerceivedComplexity: Enabled: false -# Support both ruby19 and hash_rockets -Style/HashSyntax: +Naming/VariableNumber: + Exclude: + - 'test/**/*.rb' + +Rails/SkipsModelValidations: + Exclude: + - 'db/migrate/**/*' + +Style/FormatStringToken: Enabled: false diff --git a/Gemfile b/Gemfile index fa75df1..0118d36 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,7 @@ +# frozen_string_literal: true + source 'https://rubygems.org' gemspec + +gem 'theforeman-rubocop', '~> 0.1.2' diff --git a/README.md b/README.md index cb5c440..2697a1a 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,29 @@ # Foreman Resource Quota -When several users share a compute resource or infrastructure, there is a concern that some users could use more than its fair share of resources. Resource quotas are a tool for administrators to address this concern. They limit access to the shared resource in order to guarantee a fair collaboration. +Foreman plugin to allow resource management with resource quotas among users and usergroups. -Talking about Foreman, multiple users or groups usually share a fixed number of resources (limitation of compute resources like RAM, disk storage, and CPU power). As of now, a user cannot be limited when allocating resources. They can create hosts with as many resources as they want. This could lead to over-usage or unequal balancing of resources under the users. In order to prevent this, we want to introduce a new plugin: Foreman Resource Quota. +## Installation -This plugin introduces the configuration of quotas. A quota limits specific resources and can be applied to a user or a user group. If a user belongs to a user group, the group’s quota is automatically applied to the user as well. +_TODO_ Still under development: Will be updated as soon as the Ruby gem, foreman-installer, or rpm is available. -A user is hindered from deploying new hosts, if the new host would exceed the corresponding quota limits. In case, a user belongs to multiple user group with quota, the user can determine which quota new hosts belong to. - -## Installation +## Compatibility -See [How_to_Install_a_Plugin](http://projects.theforeman.org/projects/foreman/wiki/How_to_Install_a_Plugin) -for how to install Foreman plugins +| Foreman Version | Plugin Version | +| --------------- | -------------- | +| 3.5 | 0.0.1 | ## Usage -*Usage here* +_TODO_ Still under development: Official documentation will be added soon. -## TODO +When several users share a compute resource or infrastructure, there is a concern that some users could use more than its fair share of resources. Resource quotas are a tool for administrators to address this concern. They limit access to the shared resource in order to guarantee a fair collaboration. + +In the context of Foreman, multiple users or groups usually share a fixed number of resources (limitation of compute resources like RAM, disk space, and CPU cores). As of now, a user cannot be limited when allocating resources. They can create hosts with as many resources as they want. This could lead to over-usage or unequal balancing of resources under the users. + +This plugin introduces the configuration of resource quotas. A quota limits specific resources and can be applied to a user or a user group. If a user belongs to a user group, the group’s quota is automatically applied to the user as well. When deploying a new host, a user has to choose a resource quota that the host counts to. + +A user is hindered from deploying new hosts, if the new host would exceed the corresponding quota limits. In case, a user belongs to multiple user group with quota, the user can determine which quota new hosts belong to. -*Todo list here* ## Contributing @@ -27,7 +31,7 @@ Fork and send a Pull Request. Thanks! ## Copyright -Copyright (c) *year* *your name* +Copyright (c) 2023 ATIX AG - https://atix.de This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by diff --git a/Rakefile b/Rakefile old mode 100644 new mode 100755 index e3862be..872df97 --- a/Rakefile +++ b/Rakefile @@ -1,4 +1,6 @@ #!/usr/bin/env rake +# frozen_string_literal: true + begin require 'bundler/setup' rescue LoadError @@ -14,13 +16,13 @@ end RDoc::Task.new(:rdoc) do |rdoc| rdoc.rdoc_dir = 'rdoc' - rdoc.title = 'ForemanPluginTemplate' + rdoc.title = 'ForemanResourceQuota' rdoc.options << '--line-numbers' rdoc.rdoc_files.include('README.rdoc') rdoc.rdoc_files.include('lib/**/*.rb') end -APP_RAKEFILE = File.expand_path('../test/dummy/Rakefile', __FILE__) +APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__) Bundler::GemHelper.install_tasks @@ -38,7 +40,7 @@ task default: :test begin require 'rubocop/rake_task' RuboCop::RakeTask.new -rescue => _ +rescue StandardError => _e puts 'Rubocop not loaded.' end diff --git a/app/controllers/concerns/foreman/controller/parameters/resource_quota.rb b/app/controllers/concerns/foreman/controller/parameters/resource_quota.rb new file mode 100644 index 0000000..cb950d6 --- /dev/null +++ b/app/controllers/concerns/foreman/controller/parameters/resource_quota.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Foreman + module Controller + module Parameters + module ResourceQuota + extend ActiveSupport::Concern + + class_methods do + def resource_quota_params_filter + Foreman::ParameterFilter.new(::ForemanResourceQuota::ResourceQuota).tap do |filter| + filter.permit :name + filter.permit :description + filter.permit :cpu_cores + filter.permit :memory_mb + filter.permit :disk_gb + end + end + end + + def resource_quota_params + param_name = parameter_filter_context.api? ? 'resource_quota' : 'foreman_resource_quota_resource_quota' + self.class.resource_quota_params_filter.filter_params(params, parameter_filter_context, param_name) + end + end + end + end +end diff --git a/app/controllers/foreman_resource_quota/api/v2/resource_quotas_controller.rb b/app/controllers/foreman_resource_quota/api/v2/resource_quotas_controller.rb new file mode 100644 index 0000000..84bf925 --- /dev/null +++ b/app/controllers/foreman_resource_quota/api/v2/resource_quotas_controller.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module ForemanResourceQuota + module Api + module V2 + class ResourceQuotasController < ::Api::V2::BaseController + include ::Api::Version2 + include Foreman::Controller::Parameters::ResourceQuota + + resource_description do + resource_id 'resource_quota' + api_version 'v2' + api_base_url '/foreman_resource_quota/api' + end + + before_action :find_resource, only: %i[show update destroy] + before_action :custom_find_resource, only: %i[utilization hosts users usergroups] + + api :GET, '/resource_quotas', N_('List all resource quotas') + param_group :search_and_pagination, ::Api::V2::BaseController + add_scoped_search_description_for(ForemanResourceQuota::ResourceQuota) + def index + @resource_quotas = resource_scope_for_index + end + + api :GET, '/resource_quotas/:id/', N_('Show resource quota') + param :id, :identifier, required: true + def show + end + + api :GET, '/resource_quotas/:id/utilization', N_('Show used resources of assigned hosts') + param :id, :identifier, required: true + def utilization + @resource_quota.determine_utilization + process_response @resource_quota + end + + api :GET, '/resource_quotas/:id/hosts', N_('Show hosts of a resource quota') + param :id, :identifier, required: true + def hosts + process_response @resource_quota.hosts + end + + api :GET, '/resource_quotas/:id/users', N_('Show users of a resource quota') + param :id, :identifier, required: true + def users + process_response @resource_quota.users + end + + api :GET, '/resource_quotas/:id/usergroups', N_('Show usergroups of a resource quota') + param :id, :identifier, required: true + def usergroups + process_response @resource_quota.usergroups + end + + def_param_group :resource_quota do + param :resource_quota, Hash, required: true, action_aware: true do + param :name, String, required: true + # param :operatingsystem_ids, Array, :desc => N_("Operating system IDs") + end + end + + api :POST, '/resource_quotas/', N_('Create a resource quota') + param_group :resource_quota, as: :create + def create + @resource_quota = ForemanResourceQuota::ResourceQuota.new(resource_quota_params) + process_response @resource_quota.save + end + + api :PUT, '/resource_quotas/:id/', N_('Update a resource quota') + param :id, :identifier, required: true + param_group :resource_quota + def update + process_response @resource_quota.update(resource_quota_params) + end + + api :DELETE, '/resource_quotas/:id/', N_('Delete a resource quota') + param :id, :identifier, required: true + def destroy + process_response @resource_quota.destroy + end + + def resource_class + ForemanResourceQuota::ResourceQuota + end + + private + + def custom_find_resource + @resource_quota = ForemanResourceQuota::ResourceQuota.find_by(id: params[:resource_quota_id]) + not_found unless @resource_quota + end + end + end + end +end diff --git a/app/controllers/foreman_resource_quota/application_controller.rb b/app/controllers/foreman_resource_quota/application_controller.rb new file mode 100644 index 0000000..9ac5a8d --- /dev/null +++ b/app/controllers/foreman_resource_quota/application_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ForemanResourceQuota + class ApplicationController < ::ApplicationController + def resource_class + "ForemanResourceQuota::#{controller_name.singularize.classify}".constantize + end + end +end diff --git a/app/controllers/foreman_resource_quota/resource_quotas_controller.rb b/app/controllers/foreman_resource_quota/resource_quotas_controller.rb new file mode 100644 index 0000000..f032df2 --- /dev/null +++ b/app/controllers/foreman_resource_quota/resource_quotas_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module ForemanResourceQuota + class ResourceQuotasController < ::ForemanResourceQuota::ApplicationController + include Foreman::Controller::AutoCompleteSearch + include Foreman::Controller::Parameters::ResourceQuota + + before_action :find_resource, only: %i[edit update destroy] + + def index + @resource_quotas = resource_base.search_for(params[:search], order: params[:order]).paginate(page: params[:page], + per_page: params[:per_page]) + # TODO: Check necessitiy/purpose of authorizer + # AuthorizerHelper#authorizer uses controller_name as variable name, but it fails with namespaces + # @authorizer = Authorizer.new(User.current, collection: @resource_quotas) + end + + def new + @resource_quota = ResourceQuota.new + end + + def create + @resource_quota = ResourceQuota.new(resource_quota_params) + if @resource_quota.save + process_success + else + process_error + end + end + + def edit + end + + def update + if @resource_quota.update(resource_quota_params) + process_success + else + process_error + end + end + + def destroy + if @resource_quota.destroy + process_success + else + process_error + end + end + end +end diff --git a/app/helpers/foreman_resource_quota/hosts_helper.rb b/app/helpers/foreman_resource_quota/hosts_helper.rb new file mode 100644 index 0000000..d6cf43c --- /dev/null +++ b/app/helpers/foreman_resource_quota/hosts_helper.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module ForemanResourceQuota + module HostsHelper + def resource_quota_select(form, user_quotas) + blank_opt = { include_blank: true } + select_items = user_quotas.order(:name) + select_f form, + :resource_quota_id, + select_items, + :id, + :to_label, + blank_opt, + label: _('Resource Quota'), + help_inline: _('Define the Resource Quota this host counts to.') + end + end +end diff --git a/app/helpers/foreman_resource_quota/resource_quota_helper.rb b/app/helpers/foreman_resource_quota/resource_quota_helper.rb new file mode 100644 index 0000000..f397bbd --- /dev/null +++ b/app/helpers/foreman_resource_quota/resource_quota_helper.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module ForemanResourceQuota + module ResourceQuotaHelper + FACTOR_B_TO_MB = 1024 * 1024 + FACTOR_B_TO_GB = 1024 * FACTOR_B_TO_MB + + def natural_resource_name_by_type(type) + type_names = { cpu_cores: 'CPU cores', memory_mb: 'Memory (MB)', disk_gb: 'Disk space (GB)' } + type_names[type] + end + + def build_missing_resources_per_host_list(hosts, quota_utilization) + # missing_res_per_host := { : [] } + # for example: { 1: [ :disk_gb ], 2: [ :cpu_cores, :disk_gb ] } + return {} if hosts.empty? || quota_utilization.empty? + + missing_res_per_host = hosts.map(&:id).index_with { [] } + missing_res_per_host.each_key do |host_id| + quota_utilization.each do |resource| + missing_res_per_host[host_id] << resource unless resource.nil? + end + end + missing_res_per_host + end + + def utilization_from_resource_origins(resources, hosts, use_compute_resource: true, use_vm_attributes: true, + use_facts: true) + utilization = resources.each.with_object({}) { |key, hash| hash[key] = 0 } + missing_res_per_host = build_missing_resources_per_host_list(hosts, resources) + + if use_compute_resource + ResourceOrigin::ComputeResourceOrigin.new.collect_resources!(utilization, missing_res_per_host) + end + ResourceOrigin::VMAttributesOrigin.new.collect_resources!(utilization, missing_res_per_host) if use_vm_attributes + ResourceOrigin::FactsOrigin.new.collect_resources!(utilization, missing_res_per_host) if use_facts + + [utilization, missing_res_per_host] + end + end +end diff --git a/app/models/concerns/foreman_resource_quota/host_managed_extensions.rb b/app/models/concerns/foreman_resource_quota/host_managed_extensions.rb new file mode 100644 index 0000000..442a208 --- /dev/null +++ b/app/models/concerns/foreman_resource_quota/host_managed_extensions.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module ForemanResourceQuota + module HostManagedExtensions + extend ActiveSupport::Concern + include ResourceQuotaHelper + + included do + validate :check_resource_quota_capacity + + belongs_to :resource_quota, class_name: '::ForemanResourceQuota::ResourceQuota' + scoped_search relation: :resource_quota, on: :name, complete_value: true, rename: :resource_quota + end + + # rubocop: disable Metrics/AbcSize + def check_resource_quota_capacity + return errors.empty? if early_return? + + resource_quota.determine_utilization([self]) + unless resource_quota.missing_hosts.empty? + errors.add(:resource_quota, + N_(format('An error occured reading host resources. Check the foreman error log.'))) + return false + end + + verify_resource_quota_limits(resource_quota.utilization) + errors.empty? + end + # rubocop: enable Metrics/AbcSize + + private + + def verify_resource_quota_limits(quota_utilization) + quota_utilization.each do |type, utilization| + next if utilization.nil? + max_quota = resource_quota[type] + next if utilization <= max_quota + errors.add(:resource_quota, N_(format("Host resources exceed quota '%s' for %s: %s > %s", + resource_quota_name, + natural_resource_name_by_type(type), + utilization, + max_quota))) + break + end + end + + def early_return? + if resource_quota.nil? + return true if quota_assigment_optional? + errors.add(:resource_quota, 'must be given.') + return true + end + return true if Setting[:resource_quota_global_no_action] # quota is assigned, but not supposed to be checked + false + end + + def quota_assigment_optional? + owner.resource_quota_is_optional || owner.admin || Setting[:resource_quota_global_optional_user_assignment] + end + end +end diff --git a/app/models/concerns/foreman_resource_quota/user_extensions.rb b/app/models/concerns/foreman_resource_quota/user_extensions.rb new file mode 100644 index 0000000..58f21c4 --- /dev/null +++ b/app/models/concerns/foreman_resource_quota/user_extensions.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ForemanResourceQuota + module UserExtensions + extend ActiveSupport::Concern + included do + has_many :resource_quota_users, class_name: 'ForemanResourceQuota::ResourceQuotaUser', dependent: :destroy, + inverse_of: :user + has_many :resource_quotas, class_name: 'ForemanResourceQuota::ResourceQuota', through: :resource_quota_users + attribute :resource_quota_is_optional, :boolean, default: false + + scoped_search relation: :resource_quotas, on: :name, complete_value: true, rename: :resource_quota + end + end +end diff --git a/app/models/concerns/foreman_resource_quota/usergroup_extensions.rb b/app/models/concerns/foreman_resource_quota/usergroup_extensions.rb new file mode 100644 index 0000000..c6e8c41 --- /dev/null +++ b/app/models/concerns/foreman_resource_quota/usergroup_extensions.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module ForemanResourceQuota + module UsergroupExtensions + extend ActiveSupport::Concern + included do + has_many :resource_quota_usergroups, class_name: 'ForemanResourceQuota::ResourceQuotaUsergroup', + dependent: :destroy, inverse_of: :usergroup + has_many :resource_quotas, class_name: 'ForemanResourceQuota::ResourceQuota', through: :resource_quota_usergroups + + scoped_search relation: :resource_quotas, on: :name, complete_value: true, rename: :resource_quota + end + end +end diff --git a/app/models/foreman_resource_quota/resource_quota.rb b/app/models/foreman_resource_quota/resource_quota.rb new file mode 100644 index 0000000..b07d72d --- /dev/null +++ b/app/models/foreman_resource_quota/resource_quota.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module ForemanResourceQuota + class ResourceQuota < ApplicationRecord + include ResourceQuotaHelper + include Authorizable + include Parameterizable::ByIdName + extend FriendlyId + friendly_id :name + audited + + self.table_name = 'resource_quotas' + + has_many :resource_quota_users, class_name: 'ResourceQuotaUser', inverse_of: :resource_quota, dependent: :destroy + has_many :users, class_name: '::User', through: :resource_quota_users + has_many :resource_quota_usergroups, class_name: 'ResourceQuotaUsergroup', inverse_of: :resource_quota, + dependent: :destroy + has_many :usergroups, class_name: '::Usergroup', through: :resource_quota_usergroups + has_many :hosts, class_name: '::Host::Managed', dependent: :nullify + + validates :name, presence: true, uniqueness: true + + scoped_search on: :name, complete_value: true + scoped_search on: :id, complete_enabled: false, only_explicit: true, validator: ScopedSearch::Validators::INTEGER + + attribute :utilization, :jsonb, default: {} + attribute :missing_hosts, :jsonb, default: {} + + def number_of_hosts + hosts.size + end + + def number_of_users + users.size + end + + def number_of_usergroups + usergroups.size + end + + def determine_utilization(additional_hosts: []) + quota_hosts = (hosts | (additional_hosts)) + self.utilization, self.missing_hosts = call_utilization_helper(quota_hosts) + + print_warning(missing_hosts, quota_hosts) unless missing_hosts.empty? + rescue StandardError => e + print_error(e) # print error log here and forward error + raise e + end + + def to_label + name + end + + private + + # Wrap into a function for better testing + def call_utilization_helper(quota_hosts) + utilization_from_resource_origins(active_resources, quota_hosts) + end + + def active_resources + resources = [] + %i[cpu_cores memory_mb disk_gb].each do |res| + resources << res unless self[res].nil? + end + resources + end + + def print_warning(missing_hosts, hosts) + warn_text = "Could not determines resources for #{utilization_hash.missing_hosts.size} hosts:" + missing_hosts.each do |host_id, missing_resources| + missing_host = hosts.find { |obj| obj.id == host_id } + warn_text << " '#{missing_host.name}': '#{missing_resources}'\n" + end + Rails.logger.warn warn_text + end + + def print_error(err) + Rails.logger.error("An error occured while determining resources for quota '#{name}': #{err}") + end + end +end diff --git a/app/models/foreman_resource_quota/resource_quota_user.rb b/app/models/foreman_resource_quota/resource_quota_user.rb new file mode 100644 index 0000000..ada9e4f --- /dev/null +++ b/app/models/foreman_resource_quota/resource_quota_user.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ForemanResourceQuota + class ResourceQuotaUser < ApplicationRecord + self.table_name = 'resource_quotas_users' + + belongs_to :resource_quota, inverse_of: :resource_quota_users + belongs_to :user, class_name: '::User', inverse_of: :resource_quota_users + end +end diff --git a/app/models/foreman_resource_quota/resource_quota_usergroup.rb b/app/models/foreman_resource_quota/resource_quota_usergroup.rb new file mode 100644 index 0000000..7d49614 --- /dev/null +++ b/app/models/foreman_resource_quota/resource_quota_usergroup.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ForemanResourceQuota + class ResourceQuotaUsergroup < ApplicationRecord + self.table_name = 'resource_quotas_usergroups' + + belongs_to :resource_quota, inverse_of: :resource_quota_usergroups + belongs_to :usergroup, class_name: '::Usergroup', inverse_of: :resource_quota_usergroups + end +end diff --git a/app/services/foreman_resource_quota/resource_origin.rb b/app/services/foreman_resource_quota/resource_origin.rb new file mode 100644 index 0000000..d82123c --- /dev/null +++ b/app/services/foreman_resource_quota/resource_origin.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module ForemanResourceQuota + module ResourceOrigin + class ResourceOrigin + RESOURCE_FUNCTION_MAP = { + cpu_cores: :extract_cpu_cores, + memory_mb: :extract_memory_mb, + disk_gb: :extract_disk_gb, + }.freeze + + def collect_resources!(resources_sum, missing_res_per_host) + return if missing_res_per_host.empty? + + relevant_hosts = Host::Managed.where(id: missing_res_per_host.keys).includes(host_eager_name) + relevant_hosts = relevant_hosts.compact + host_values = collect_attribute_from_hosts(relevant_hosts, host_attribute_name) + host_values.each do |host_id, attribute_content| + missing_res_per_host[host_id].reverse_each do |resource_name| + process_resource!(resources_sum, missing_res_per_host, resource_name, host_id, + attribute_content) + end + missing_res_per_host.delete(host_id) if missing_res_per_host[host_id].empty? + end + end + + def host_eager_name + raise NotImplementedError + end + + def host_attribute_name + raise NotImplementedError + end + + def extract_cpu_cores(param) + raise NotImplementedError + end + + def extract_memory_mb(param) + raise NotImplementedError + end + + def extract_disk_gb(param) + raise NotImplementedError + end + + private + + def collect_attribute_from_hosts(host_list, attribute_name) + host_values = [] + host_list.each do |host| + host_attribute = host.send(attribute_name) + host_values << [host.id, host_attribute] if host_attribute.present? + rescue StandardError + # skip hosts whose attribute couldn't be collected + end + host_values + end + + def process_resource!(resources_sum, missing_res_per_host, resource_name, host_id, attribute_content) + resource_value = method(RESOURCE_FUNCTION_MAP[resource_name]).call(attribute_content) + return unless resource_value + + resources_sum[resource_name] += resource_value + missing_res_per_host[host_id].delete(resource_name) + end + end + end +end diff --git a/app/services/foreman_resource_quota/resource_origins/compute_resource_origin.rb b/app/services/foreman_resource_quota/resource_origins/compute_resource_origin.rb new file mode 100644 index 0000000..203b511 --- /dev/null +++ b/app/services/foreman_resource_quota/resource_origins/compute_resource_origin.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module ForemanResourceQuota + module ResourceOrigin + class ComputeResourceOrigin < ResourceOrigin + def extract_cpu_cores(param) + param.cpus + rescue StandardError + nil + end + + def extract_memory_mb(param) + param.memory.to_i / FACTOR_B_TO_MB + rescue StandardError + nil + end + + def extract_disk_gb(_) + # FIXME: no disk given in VM (compare fog extensions) + nil + end + + def collect_resources!(resources_sum, missing_res_per_host) + compute_resource_to_hosts = group_hosts_by_compute_resource(missing_res_per_host.keys) + + compute_resource_to_hosts.each do |compute_resource_id, hosts| + next if compute_resource_id == :nil_compute_resource + + host_vms, vm_id_attr = filter_vms_by_hosts(hosts, compute_resource_id) + next if host_vms.empty? + + hosts.each do |host| + process_host_vm!(resources_sum, missing_res_per_host, host, host_vms, vm_id_attr) + end + end + end + + def process_host_vm!(resources_sum, missing_res_per_host, host, host_vms, vm_id_attr) + vm = host_vms.find { |obj| obj.send(vm_id_attr) == host.uuid } + return unless vm + + missing_res_per_host[host.id].reverse_each do |resource_name| # reverse: delete items while iterating + process_resource!(resources_sum, missing_res_per_host, resource_name, host.id, vm) + end + missing_res_per_host.delete(host.id) if missing_res_per_host[host.id].empty? + end + + def group_hosts_by_compute_resource(host_ids) + Host::Managed.where(id: host_ids).includes(:compute_resource).group_by do |host| + host.compute_resource&.id || :nil_compute_resource + end + end + + def filter_vms_by_hosts(hosts, compute_resource_id) + host_uuids = hosts.map(&:uuid) + vms = ComputeResource.find_by(id: compute_resource_id).vms.all + id_attr = vms[0].respond_to?(:vmid) ? :vmid : :id + filtered_vms = vms.select { |obj| host_uuids.include?(obj.send(id_attr)) } # reduce from all vms + [filtered_vms, id_attr] + end + end + end +end diff --git a/app/services/foreman_resource_quota/resource_origins/facts_origin.rb b/app/services/foreman_resource_quota/resource_origins/facts_origin.rb new file mode 100644 index 0000000..05c60e4 --- /dev/null +++ b/app/services/foreman_resource_quota/resource_origins/facts_origin.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module ForemanResourceQuota + module ResourceOrigin + class FactsOrigin < ResourceOrigin + FACTS_KEYS_CPU_CORES = [ + 'ansible_processor_cores', + 'facter_processors::cores', + 'proc_cpuinfo::common::cpu_cores', + 'processors::cores', + ].freeze + + FACTS_KEYS_MEMORY_B = [ + 'facter_memory::system::total_bytes', + 'memory::system::total_bytes', + 'memory::memtotal', + ].freeze + + FACTS_REGEX_DISK_B = [ + /^disks::(\w+)::size_bytes$/, + /^facter_disks::(\w+)::size_bytes$/, + ].freeze + + def host_eager_name + :fact_values + end + + def host_attribute_name + :facts + end + + def extract_cpu_cores(param) + common_keys = param.keys & FACTS_KEYS_CPU_CORES + return param[common_keys.first].to_i if common_keys.any? + nil + rescue StandardError + nil + end + + def extract_memory_mb(param) + common_keys = param.keys & FACTS_KEYS_MEMORY_B + return (param[common_keys.first].to_i / FACTOR_B_TO_MB).to_i if common_keys.any? + nil + rescue StandardError + nil + end + + def extract_disk_gb(param) + total_gb = nil + + FACTS_REGEX_DISK_B.each do |regex| + relevant_keys = param.keys.grep(regex) + next unless relevant_keys.any? + + total_gb = sum_disk_space(param, relevant_keys) + end + + total_gb&.to_i + rescue StandardError + nil + end + + def sum_disk_space(facts, keys) + keys.map { |key| facts[key].to_i }.sum / FACTOR_B_TO_GB unless keys.empty? + end + end + end +end diff --git a/app/services/foreman_resource_quota/resource_origins/vm_attributes_origin.rb b/app/services/foreman_resource_quota/resource_origins/vm_attributes_origin.rb new file mode 100644 index 0000000..b9e32d3 --- /dev/null +++ b/app/services/foreman_resource_quota/resource_origins/vm_attributes_origin.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module ForemanResourceQuota + module ResourceOrigin + class VMAttributesOrigin < ResourceOrigin + def host_eager_name + :compute_resource + end + + def host_attribute_name + :vm_compute_attributes + end + + def extract_cpu_cores(param) + param[:cpus] + rescue StandardError + nil + end + + def extract_memory_mb(param) + param[:memory_mb] + rescue StandardError + nil + end + + def extract_disk_gb(param) + param[:volumes_attributes].values.sum { |disk| disk[:size_gb].to_i } + rescue StandardError + nil + end + end + end +end diff --git a/app/views/foreman_resource_quota/api/v2/hosts/resource_quota.json.rabl b/app/views/foreman_resource_quota/api/v2/hosts/resource_quota.json.rabl new file mode 100644 index 0000000..4097c07 --- /dev/null +++ b/app/views/foreman_resource_quota/api/v2/hosts/resource_quota.json.rabl @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +attribute :resource_quota_id, :resource_quota_name diff --git a/app/views/foreman_resource_quota/api/v2/resource_quotas/base.json.rabl b/app/views/foreman_resource_quota/api/v2/resource_quotas/base.json.rabl new file mode 100644 index 0000000..6b98f79 --- /dev/null +++ b/app/views/foreman_resource_quota/api/v2/resource_quotas/base.json.rabl @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +object @resource_quota + +attributes :name, :id, :description, :cpu_cores, :memory_mb, :disk_gb, :number_of_hosts, :number_of_users, + :number_of_usergroups diff --git a/app/views/foreman_resource_quota/api/v2/resource_quotas/create.json.rabl b/app/views/foreman_resource_quota/api/v2/resource_quotas/create.json.rabl new file mode 100644 index 0000000..d7d1dc3 --- /dev/null +++ b/app/views/foreman_resource_quota/api/v2/resource_quotas/create.json.rabl @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +object @resource_quota + +extends 'api/v2/resource_quotas/show' diff --git a/app/views/foreman_resource_quota/api/v2/resource_quotas/hosts.json.rabl b/app/views/foreman_resource_quota/api/v2/resource_quotas/hosts.json.rabl new file mode 100644 index 0000000..af1c677 --- /dev/null +++ b/app/views/foreman_resource_quota/api/v2/resource_quotas/hosts.json.rabl @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +object @resource_quota + +child :hosts do + extends 'api/v2/hosts/base' +end diff --git a/app/views/foreman_resource_quota/api/v2/resource_quotas/index.json.rabl b/app/views/foreman_resource_quota/api/v2/resource_quotas/index.json.rabl new file mode 100644 index 0000000..5990f39 --- /dev/null +++ b/app/views/foreman_resource_quota/api/v2/resource_quotas/index.json.rabl @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +collection @resource_quotas + +extends 'api/v2/resource_quotas/main' diff --git a/app/views/foreman_resource_quota/api/v2/resource_quotas/main.json.rabl b/app/views/foreman_resource_quota/api/v2/resource_quotas/main.json.rabl new file mode 100644 index 0000000..bbe56ba --- /dev/null +++ b/app/views/foreman_resource_quota/api/v2/resource_quotas/main.json.rabl @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +object @resource_quota + +extends 'api/v2/resource_quotas/base' + +attributes :created_at, :updated_at diff --git a/app/views/foreman_resource_quota/api/v2/resource_quotas/show.json.rabl b/app/views/foreman_resource_quota/api/v2/resource_quotas/show.json.rabl new file mode 100644 index 0000000..446489d --- /dev/null +++ b/app/views/foreman_resource_quota/api/v2/resource_quotas/show.json.rabl @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +object @resource_quota + +extends 'api/v2/resource_quotas/main' diff --git a/app/views/foreman_resource_quota/api/v2/resource_quotas/update.json.rabl b/app/views/foreman_resource_quota/api/v2/resource_quotas/update.json.rabl new file mode 100644 index 0000000..d7d1dc3 --- /dev/null +++ b/app/views/foreman_resource_quota/api/v2/resource_quotas/update.json.rabl @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +object @resource_quota + +extends 'api/v2/resource_quotas/show' diff --git a/app/views/foreman_resource_quota/api/v2/resource_quotas/usergroups.json.rabl b/app/views/foreman_resource_quota/api/v2/resource_quotas/usergroups.json.rabl new file mode 100644 index 0000000..161cccc --- /dev/null +++ b/app/views/foreman_resource_quota/api/v2/resource_quotas/usergroups.json.rabl @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +object @resource_quota + +child :usergroups do + extends 'api/v2/usergroups/base' +end diff --git a/app/views/foreman_resource_quota/api/v2/resource_quotas/users.json.rabl b/app/views/foreman_resource_quota/api/v2/resource_quotas/users.json.rabl new file mode 100644 index 0000000..37f0816 --- /dev/null +++ b/app/views/foreman_resource_quota/api/v2/resource_quotas/users.json.rabl @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +object @resource_quota + +child :users do + extends 'api/v2/users/base' +end diff --git a/app/views/foreman_resource_quota/api/v2/resource_quotas/utilization.json.rabl b/app/views/foreman_resource_quota/api/v2/resource_quotas/utilization.json.rabl new file mode 100644 index 0000000..a8fb6c7 --- /dev/null +++ b/app/views/foreman_resource_quota/api/v2/resource_quotas/utilization.json.rabl @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +object @resource_quota + +extends 'api/v2/resource_quotas/main' + +attributes :utilization diff --git a/app/views/foreman_resource_quota/api/v2/usergroups/resource_quota.json.rabl b/app/views/foreman_resource_quota/api/v2/usergroups/resource_quota.json.rabl new file mode 100644 index 0000000..e682ab6 --- /dev/null +++ b/app/views/foreman_resource_quota/api/v2/usergroups/resource_quota.json.rabl @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +attribute :resource_quota_ids, :resource_quota_names diff --git a/app/views/foreman_resource_quota/api/v2/users/resource_quota.json.rabl b/app/views/foreman_resource_quota/api/v2/users/resource_quota.json.rabl new file mode 100644 index 0000000..e682ab6 --- /dev/null +++ b/app/views/foreman_resource_quota/api/v2/users/resource_quota.json.rabl @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +attribute :resource_quota_ids, :resource_quota_names diff --git a/app/views/foreman_resource_quota/layouts/layouts/new_layout.html.erb b/app/views/foreman_resource_quota/layouts/layouts/new_layout.html.erb deleted file mode 100644 index e69de29..0000000 diff --git a/app/views/foreman_resource_quota/layouts/new_layout.html.erb b/app/views/foreman_resource_quota/layouts/new_layout.html.erb deleted file mode 100644 index e69de29..0000000 diff --git a/app/views/foreman_resource_quota/resource_quotas/_form.html.erb b/app/views/foreman_resource_quota/resource_quotas/_form.html.erb new file mode 100644 index 0000000..5cca68b --- /dev/null +++ b/app/views/foreman_resource_quota/resource_quotas/_form.html.erb @@ -0,0 +1,21 @@ +<% + if @resource_quota.new_record? + react_data = { + "isNewQuota": true, + } + else + react_data = { + "isNewQuota": false, + "initialProperties": { + "id": @resource_quota.id, + "name": @resource_quota.name, + "description": @resource_quota.description, + "cpu_cores": @resource_quota.cpu_cores, + "memory_mb": @resource_quota.memory_mb, + "disk_gb": @resource_quota.disk_gb, + }, + } + end +%> + +<%= react_component('ResourceQuotaForm', react_data) %> diff --git a/app/views/foreman_resource_quota/resource_quotas/edit.html.erb b/app/views/foreman_resource_quota/resource_quotas/edit.html.erb new file mode 100644 index 0000000..0b2a9bf --- /dev/null +++ b/app/views/foreman_resource_quota/resource_quotas/edit.html.erb @@ -0,0 +1,12 @@ +<% title(_('Edit Resource Quota %s') % @resource_quota) %> +<%= breadcrumbs(resource_url: api_resource_quotas_path, + switcher_item_url: edit_resource_quota_path(':id')) %> + +<% content_for(:javascripts) do %> + <%= webpacked_plugins_js_for :foreman_resource_quota %> +<% end %> +<% content_for(:stylesheets) do %> + <%= webpacked_plugins_css_for :foreman_resource_quota %> +<% end %> + +<%= render :partial => 'form' %> diff --git a/app/views/foreman_resource_quota/resource_quotas/index.html.erb b/app/views/foreman_resource_quota/resource_quotas/index.html.erb new file mode 100644 index 0000000..bdcef0a --- /dev/null +++ b/app/views/foreman_resource_quota/resource_quotas/index.html.erb @@ -0,0 +1,55 @@ +<% content_for(:javascripts) do %> + <%= webpacked_plugins_js_for :foreman_resource_quota %> +<% end %> +<% content_for(:stylesheets) do %> + <%= webpacked_plugins_css_for :foreman_resource_quota %> +<% end %> + +<% title _('Resource quotas') %> + +<%= title_actions react_component('CreateResourceQuotaModal') %> + + + + + + + + + + + + + + <% @resource_quotas.each do |quota| + react_data = { + "isNewQuota": false, + "initialProperties": { + "id": quota.id, + "name": quota.name, + "description": quota.description, + "cpu_cores": quota.cpu_cores, + "memory_mb": quota.memory_mb, + "disk_gb": quota.disk_gb, + }, + } + %> + + + + + + + + + <% end %> + +
<%= sort :name, :as => s_('Resource Quota|Name') %><%= _('Description') %><%= _('CPU cores') %><%= _('Memory (MB)') %><%= _('Disk space (GB)') %><%= _('Actions') %>
+ <%= react_component('UpdateResourceQuotaModal', react_data) %> + <%= h(quota.description) %><%= h(quota.cpu_cores) %><%= h(quota.memory_mb) %><%= h(quota.disk_gb) %> + <%= action_buttons( + display_delete_if_authorized(hash_for_foreman_resource_quota_resource_quota_path(id: quota), data: { confirm: _("Delete %s?") % quota.name}) + ) %> +
+ +<%= will_paginate_with_info @resource_quotas %> diff --git a/app/views/foreman_resource_quota/resource_quotas/new.html.erb b/app/views/foreman_resource_quota/resource_quotas/new.html.erb new file mode 100644 index 0000000..d388b31 --- /dev/null +++ b/app/views/foreman_resource_quota/resource_quotas/new.html.erb @@ -0,0 +1,10 @@ +<% title _('New Resource Quota') %> + +<% content_for(:javascripts) do %> + <%= webpacked_plugins_js_for :foreman_resource_quota %> +<% end %> +<% content_for(:stylesheets) do %> + <%= webpacked_plugins_css_for :foreman_resource_quota %> +<% end %> + +<%= render :partial => 'form' %> diff --git a/app/views/foreman_resource_quota/resource_quotas/welcome.html.erb b/app/views/foreman_resource_quota/resource_quotas/welcome.html.erb new file mode 100644 index 0000000..b8c851c --- /dev/null +++ b/app/views/foreman_resource_quota/resource_quotas/welcome.html.erb @@ -0,0 +1,10 @@ +<% content_for(:javascripts) do %> + <%= webpacked_plugins_js_for :foreman_resource_quota %> +<% end %> +<% content_for(:stylesheets) do %> + <%= webpacked_plugins_css_for :foreman_resource_quota %> +<% end %> + +<% content_for(:title, _("Resource Quotas")) %> + +<%= react_component('ResourceQuotaEmptyState') %> diff --git a/app/views/hosts/_form_quota_fields.html.erb b/app/views/hosts/_form_quota_fields.html.erb new file mode 100644 index 0000000..5318a7d --- /dev/null +++ b/app/views/hosts/_form_quota_fields.html.erb @@ -0,0 +1,4 @@ +<% + user_quotas = User.current&.admin? ? ForemanResourceQuota::ResourceQuota.all : User.current.resource_quotas +%> +<%= resource_quota_select form, user_quotas %> diff --git a/app/views/users/_form_quota_tab.html.erb b/app/views/users/_form_quota_tab.html.erb new file mode 100644 index 0000000..6efb237 --- /dev/null +++ b/app/views/users/_form_quota_tab.html.erb @@ -0,0 +1,45 @@ +<% resource_type ||= pagelet.opts[:resource_type] %> +<% if resource_type == :user %> + <% resource = @user %> +<% elsif resource_type == :usergroup %> + <% resource = @usergroup %> +<% end %> + +<% if resource_type == :user %> + <%= checkbox_f form, :"resource_quota_is_optional", :label => _("Optional Assignment"), + :label_help => _("It is optional for a user to assign a quota when creating new hosts") %> +<% end %> + +<%= multiple_checkboxes(form, :resource_quotas, resource, ForemanResourceQuota::ResourceQuota, :label => _("Resource Quotas")) %> + +<% if resource_type == :user %> + <% usergroups = @user.cached_usergroups.includes(:resource_quotas).distinct %> + <% if usergroups.any? %> +
+ +
+ +
    + <% usergroups.each do |usergroup| %> + <% unless usergroup.resource_quotas.map(&:name).any? %> +
  • <%= _('This group has no quotas') %>
  • + <%end %> + <% usergroup.resource_quotas.map(&:name).each do |quota_name| %> +
  • <%= quota_name %>
  • + <% end %> + <% end %> +
+
+
+ <% end %> +<% end %> diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 0000000..ec56fce --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +ActiveSupport::Inflector.inflections do |inflect| + inflect.irregular 'resource_quota', 'resource_quotas' +end diff --git a/config/routes.rb b/config/routes.rb index 3e53d4a..7c832ed 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,9 +1,43 @@ -ForemanPluginTemplate::Engine.routes.draw do - get 'new_action', to: 'example#new_action', as: 'new_action' - get 'plugin_template_description', to: 'example#react_template_page_description' - get 'welcome', to: '/react#index', as: 'welcome' -end +# frozen_string_literal: true + +# rubocop: disable Metrics/BlockLength +Rails.application.routes.draw do + scope :foreman_resource_quota, path: 'foreman_resource_quota' do + resources :resource_quotas, except: %i[show], controller: 'foreman_resource_quota/resource_quotas' do + collection do + get 'auto_complete_search' + end + end + end + + namespace :foreman_resource_quota do + resources :resource_quotas, except: %i[show] do + collection do + get 'help', action: :welcome + get 'auto_complete_search' + end + end -Foreman::Application.routes.draw do - mount ForemanPluginTemplate::Engine, at: '/foreman_resource_quota' + # API routes + namespace :api, defaults: { format: 'json' } do + scope '(:apiv)', + module: :v2, + defaults: { apiv: 'v2' }, + apiv: /v1|v2/, + constraints: ApiConstraints.new(version: 2, default: true) do + resources :resource_quotas, except: %i[new edit] do + collection do + get 'auto_complete_search' + end + constraints(id: %r{[^/]+}) do + get 'utilization' + get 'hosts' + get 'users' + get 'usergroups' + end + end + end + end + end end +# rubocop: enable Metrics/BlockLength diff --git a/db/migrate/20230306120001_create_resource_quotas.rb b/db/migrate/20230306120001_create_resource_quotas.rb new file mode 100644 index 0000000..371c4b2 --- /dev/null +++ b/db/migrate/20230306120001_create_resource_quotas.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class CreateResourceQuotas < ActiveRecord::Migration[6.1] + # rubocop: disable Metrics/AbcSize + def change + create_table :resource_quotas do |t| + t.string :name, null: false + t.text :description + t.integer :cpu_cores, default: nil + t.integer :memory_mb, default: nil + t.integer :disk_gb, default: nil + + t.timestamps + end + + create_table :resource_quotas_usergroups do |t| + t.belongs_to :resource_quota + t.belongs_to :usergroup + t.timestamps + end + + create_table :resource_quotas_users do |t| + t.belongs_to :resource_quota + t.belongs_to :user + t.timestamps + end + add_reference :hosts, :resource_quota, foreign_key: { to_table: :resource_quotas } + add_column :users, :resource_quota_is_optional, :boolean, default: false + end + # rubocop: enable Metrics/AbcSize +end diff --git a/foreman_resource_quota.gemspec b/foreman_resource_quota.gemspec index 9e287b5..562838f 100644 --- a/foreman_resource_quota.gemspec +++ b/foreman_resource_quota.gemspec @@ -1,20 +1,18 @@ -require File.expand_path('../lib/foreman_resource_quota/version', __FILE__) +# frozen_string_literal: true + +require File.expand_path('lib/foreman_resource_quota/version', __dir__) Gem::Specification.new do |s| s.name = 'foreman_resource_quota' - s.version = ForemanPluginTemplate::VERSION - s.metadata = { "is_foreman_plugin" => "true" } + s.version = ForemanResourceQuota::VERSION + s.metadata = { 'is_foreman_plugin' => 'true' } s.license = 'GPL-3.0' - s.authors = ['TODO: Your name'] - s.email = ['TODO: Your email'] - s.homepage = 'TODO' - s.summary = 'TODO: Summary of ForemanPluginTemplate.' + s.authors = ['Bastian Schmidt'] + s.email = ['schmidt@atix.de'] + s.homepage = 'https://github.com/ATIX-AG/foreman_resource_quota' + s.summary = 'Foreman Plug-in for resource quota' # also update locale/gemspec.rb - s.description = 'TODO: Description of ForemanPluginTemplate.' + s.description = 'Foreman Plug-in to manage resource usage among users.' s.files = Dir['{app,config,db,lib,locale,webpack}/**/*'] + ['LICENSE', 'Rakefile', 'README.md', 'package.json'] - s.test_files = Dir['test/**/*'] + Dir['webpack/**/__tests__/*.js'] - - s.add_development_dependency 'rubocop' - s.add_development_dependency 'rdoc' end diff --git a/lib/foreman_resource_quota.rb b/lib/foreman_resource_quota.rb new file mode 100644 index 0000000..dfd818f --- /dev/null +++ b/lib/foreman_resource_quota.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require 'foreman_resource_quota/engine' + +module ForemanResourceQuota +end diff --git a/lib/foreman_resource_quota/engine.rb b/lib/foreman_resource_quota/engine.rb new file mode 100644 index 0000000..4fbb909 --- /dev/null +++ b/lib/foreman_resource_quota/engine.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module ForemanResourceQuota + class Engine < ::Rails::Engine + engine_name 'foreman_resource_quota' + + config.autoload_paths += Dir["#{config.root}/app/services/foreman_resource_quota"] + config.autoload_paths += Dir["#{config.root}/app/helpers/foreman_resource_quota"] + config.autoload_paths += Dir["#{config.root}/app/controllers/foreman_resource_quota"] + config.autoload_paths += Dir["#{config.root}/app/models/"] + config.autoload_paths += Dir["#{config.root}/app/views/foreman_resource_quota"] + + # Add db migrations + initializer 'foreman_resource_quota.load_app_instance_data' do |app| + ForemanResourceQuota::Engine.paths['db/migrate'].existent.each do |path| + app.config.paths['db/migrate'] << path + end + end + + # Apipie + initializer 'foreman_resource_quota.apipie' do + Apipie.configuration.checksum_path += ['/foreman_resource_quota/api/'] + Rabl.configure do |config| + config.view_paths << ForemanResourceQuota::Engine.root.join('app', 'views', 'foreman_resource_quota') + end + end + + # Rake tasks + rake_tasks do + Rake::Task['db:seed'].enhance do + ForemanResourceQuota::Engine.load_seed + end + end + + # Plugin extensions + initializer 'foreman_resource_quota.register_plugin', before: :finisher_hook do |_app| + require 'foreman_resource_quota/register' + end + + # Include concerns in this config.to_prepare block + config.to_prepare do + ::User.include ForemanResourceQuota::UserExtensions + ::Usergroup.include ForemanResourceQuota::UsergroupExtensions + ::Host::Managed.include ForemanResourceQuota::HostManagedExtensions + rescue StandardError => e + Rails.logger.warn "ForemanResourceQuota: skipping engine hook (#{e})" + end + + initializer 'foreman_resource_quota.register_gettext', after: :load_config_initializers do |_app| + locale_dir = File.join(File.expand_path('../..', __dir__), 'locale') + locale_domain = 'foreman_resource_quota' + Foreman::Gettext::Support.add_text_domain locale_domain, locale_dir + end + end +end diff --git a/lib/foreman_resource_quota/register.rb b/lib/foreman_resource_quota/register.rb new file mode 100644 index 0000000..96c9f88 --- /dev/null +++ b/lib/foreman_resource_quota/register.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +# rubocop: disable Metrics/BlockLength +Foreman::Plugin.register :foreman_resource_quota do + requires_foreman '>= 3.5.0' + # Apipie + apipie_documented_controllers ["#{ForemanResourceQuota::Engine.root}" \ + '/app/controllers/foreman_resource_quot/api/v2/*.rb'] + + # Add permissions + security_block :foreman_resource_quota do + permission 'view_foreman_resource_quota/resource_quotas', + { 'foreman_resource_quota/resource_quotas': %i[index auto_complete_search], + 'foreman_resource_quota/api/v2/resource_quotas': %i[index show utilization hosts users usergroups], + 'foreman_resource_quota/api/v2/resource_quotas/:resource_quota_id/': %i[utilization hosts users usergroups] }, + resource_type: 'ForemanResourceQuota::ResourceQuota' + permission 'create_foreman_resource_quota/resource_quotas', + { 'foreman_resource_quota/resource_quotas': %i[new create], + 'foreman_resource_quota/api/v2/resource_quotas': %i[create] }, + resource_type: 'ForemanResourceQuota::ResourceQuota' + permission 'edit_foreman_resource_quota/resource_quotas', + { 'foreman_resource_quota/resource_quotas': %i[edit update], + 'foreman_resource_quota/api/v2/resource_quotas': %i[update] }, + resource_type: 'ForemanResourceQuota::ResourceQuota' + permission 'destroy_foreman_resource_quota/resource_quotas', + { 'foreman_resource_quota/resource_quotas': %i[destroy], + 'foreman_resource_quota/api/v2/resource_quotas': %i[destroy] }, + resource_type: 'ForemanResourceQuota::ResourceQuota' + + # TODO: Evaluate whether host/user/usergroup permission extensions are necessary + end + + # Add a permissions to default roles (Viewer and Manager) + role 'Resource Quota Manager', ['view_foreman_resource_quota/resource_quotas', + 'create_foreman_resource_quota/resource_quotas', + 'edit_foreman_resource_quota/resource_quotas', + 'destroy_foreman_resource_quota/resource_quotas', + 'view_hosts', + 'edit_hosts', + 'view_users', + 'edit_users'] + role 'Resource Quota User', ['view_foreman_resource_quota/resource_quotas', + 'view_hosts', + 'view_users', + 'view_usergroups'] + add_all_permissions_to_default_roles + + # add controller parameter extension + parameter_filter User, { resource_quotas: [], resource_quota_ids: [] }, :resource_quota_is_optional + parameter_filter Usergroup, { resource_quotas: [], resource_quota_ids: [] } + parameter_filter Host::Managed, :resource_quota_id + + # add UI menu extension + add_menu_item :top_menu, :resource_quotas, + url_hash: { controller: 'foreman_resource_quota/resource_quotas', action: :index }, + caption: N_('Resource Quotas'), + parent: :configure_menu, + after: :common_parameters + + # add API extension + extend_rabl_template 'api/v2/hosts/main', 'foreman_resource_quota/api/v2/hosts/resource_quota' + extend_rabl_template 'api/v2/users/main', 'foreman_resource_quota/api/v2/users/resource_quota' + extend_rabl_template 'api/v2/usergroups/main', 'foreman_resource_quota/api/v2/usergroups/resource_quota' + + # add UI user/usergroup/hosts extension + extend_page 'users/_form' do |cx| + cx.add_pagelet :user_tabs, + id: :quota_user_tab, + name: N_('Resource Quota'), + resource_type: :user, + partial: 'users/form_quota_tab' + end + extend_page 'usergroups/_form' do |cx| + cx.add_pagelet :usergroup_tabs, + id: :quota_usergroup_tab, + name: N_('Resource Quota'), + resource_type: :usergroup, + partial: 'users/form_quota_tab' + end + extend_page 'hosts/_form' do |cx| + cx.add_pagelet :main_tab_fields, + id: :quota_hosts_tab_fields, + resource_type: :host, + partial: 'hosts/form_quota_fields' + end + + # Add global Foreman settings + settings do + category :provisioning do + setting 'resource_quota_global_optional_user_assignment', + type: :boolean, + default: true, + full_name: N_('Global Resource Quota optional assignment'), + description: N_('Overwrite user-specific configurations and make the Resource Quota assignment + during host deployment optional for everyone.') + setting 'resource_quota_global_no_action', + type: :boolean, + default: true, + full_name: N_('Global Resource Quota no action'), + description: N_('Take no action when a resource quota is exceeded.') + # Future: Overwrite quota-specific "out of resource"-action and take no .. + end + end +end +# rubocop: enable Metrics/BlockLength diff --git a/lib/foreman_resource_quota/version.rb b/lib/foreman_resource_quota/version.rb new file mode 100644 index 0000000..9625fb3 --- /dev/null +++ b/lib/foreman_resource_quota/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module ForemanResourceQuota + VERSION = '0.0.1' +end diff --git a/lib/tasks/foreman_resource_quota_tasks.rake b/lib/tasks/foreman_resource_quota_tasks.rake new file mode 100644 index 0000000..13fa749 --- /dev/null +++ b/lib/tasks/foreman_resource_quota_tasks.rake @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'rake/testtask' + +# Tasks +namespace :foreman_resource_quota do + namespace :example do + desc 'Example Task' + task task: :environment do + # Task goes here + end + end +end + +# Tests +namespace :test do + desc 'Test ForemanResourceQuota' + Rake::TestTask.new(:foreman_resource_quota) do |t| + test_dir = File.expand_path('../../test', __dir__) + t.libs << 'test' + t.libs << test_dir + t.pattern = "#{test_dir}/**/*_test.rb" + t.verbose = true + t.warning = false + end +end + +namespace :foreman_resource_quota do + task rubocop: :environment do + begin + require 'rubocop/rake_task' + RuboCop::RakeTask.new(:rubocop_foreman_resource_quota) do |task| + task.patterns = ["#{ForemanResourceQuota::Engine.root}/app/**/*.rb", + "#{ForemanResourceQuota::Engine.root}/lib/**/*.rb", + "#{ForemanResourceQuota::Engine.root}/test/**/*.rb"] + end + rescue StandardError + puts 'Rubocop not loaded.' + end + + Rake::Task['rubocop_foreman_resource_quota'].invoke + end +end + +Rake::Task[:test].enhance ['test:foreman_resource_quota'] + +load 'tasks/jenkins.rake' +if Rake::Task.task_defined?(:'jenkins:unit') + Rake::Task['jenkins:unit'].enhance ['test:foreman_resource_quota', 'foreman_resource_quota:rubocop'] +end diff --git a/locale/en/foreman_resource_quota.po b/locale/en/foreman_resource_quota.po new file mode 100644 index 0000000..5926e09 --- /dev/null +++ b/locale/en/foreman_resource_quota.po @@ -0,0 +1,18 @@ +# foreman_resource_quota +# +# This file is distributed under the same license as foreman_resource_quota. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: version 0.0.1\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2014-08-20 08:46+0100\n" +"PO-Revision-Date: 2014-08-20 08:54+0100\n" +"Last-Translator: Foreman Team \n" +"Language-Team: Foreman Team \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" diff --git a/locale/gemspec.rb b/locale/gemspec.rb index e867059..7a279d3 100644 --- a/locale/gemspec.rb +++ b/locale/gemspec.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + # Matches foreman_resource_quota.gemspec -_('TODO: Description of ForemanPluginTemplate.') +_('Foreman Plug-in to manage resource usage among users.') diff --git a/package.json b/package.json index 6ddef59..45dadc4 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "devDependencies": { "@babel/core": "^7.7.0", "@sheerun/mutationobserver-shim": "^0.3.3", + "@testing-library/react": "^10.4.9", "@theforeman/builder": "^6.0.0", "@theforeman/eslint-plugin-foreman": "6.0.0", "@theforeman/find-foreman": "^4.8.0", @@ -38,7 +39,8 @@ "babel-eslint": "^10.0.3", "eslint": "^6.7.2", "prettier": "^1.19.1", - "stylelint-config-standard": "^18.0.0", - "stylelint": "^9.3.0" + "react-redux-test-utils": "^0.2.0", + "stylelint": "^9.3.0", + "stylelint-config-standard": "^18.0.0" } } diff --git a/rename.rb b/rename.rb deleted file mode 100755 index 029923f..0000000 --- a/rename.rb +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env ruby -require 'find' -require 'fileutils' - -class String - def camel_case - return self if self !~ /_/ && self =~ /[A-Z]+.*/ - split('_').map(&:capitalize).join - end -end - -def usage - puts 'This script renames the template plugin to a name of your choice' - puts 'Please supply the desired plugin name in snake_case, e.g.' - puts '' - puts ' rename.rb my_awesome_plugin' - puts '' - exit 0 -end - -usage if ARGV.size != 1 - -snake = ARGV[0] -camel = snake.camel_case -camel_lower = camel[0].downcase + camel[1..-1] - -if snake == camel - puts "Could not camelize '#{snake}' - exiting" - exit 1 -end - -old_dirs = [] -Find.find('.') do |path| - next unless File.file?(path) - next if path =~ /\.git/ - next if path == './rename.rb' - - # Change content on all files - tmp_file = "#{path}.tmp" - system(%(sed 's/foreman_plugin_template/#{snake}/g' #{path} > #{tmp_file})) - system(%(sed 's/ForemanPluginTemplate/#{camel}/g' #{tmp_file} > #{path})) - system(%(sed 's/foremanPluginTemplate/#{camel_lower}/g' #{tmp_file} > #{path})) - system(%(rm #{tmp_file})) -end - -Find.find('.') do |path| - # Change all the paths to the new snake_case name - if path =~ /foreman_plugin_template/i - new = path.gsub('foreman_plugin_template', snake) - # Recursively copy the directory and store the original for deletion - # Check for $ because we don't need to copy template/hosts for example - if File.directory?(path) && path =~ /foreman_plugin_template$/i - FileUtils.cp_r(path, new) - old_dirs << path - else - # gsub replaces all instances, so it will work on the new directories - FileUtils.mv(path, new) - end - end -end - -# Clean up -FileUtils.rm_rf(old_dirs) - -FileUtils.mv('README.plugin.md', 'README.md') - -puts 'All done!' -puts "Add this to Foreman's bundler configuration:" -puts '' -puts " gem '#{snake}', :path => '#{Dir.pwd}'" -puts '' -puts 'Happy hacking!' diff --git a/test/controllers/api/v2/resource_quotas_test.rb b/test/controllers/api/v2/resource_quotas_test.rb new file mode 100644 index 0000000..1135865 --- /dev/null +++ b/test/controllers/api/v2/resource_quotas_test.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require 'test_plugin_helper' +require 'byebug' + +module ForemanResourceQuota + module Api + module V2 + class ResourceQuotasControllerTest < ActionController::TestCase + tests ForemanResourceQuota::Api::V2::ResourceQuotasController + def setup + User.current = User.find_by login: 'admin' + @quota = FactoryBot.create :resource_quota + as_admin { @quota.save! } + end + + def add_quota + quota = FactoryBot.create :resource_quota + as_admin { @quota.save! } + quota + end + + test 'should get index with quotas' do + get :index, session: set_session_user + assert_response :success + index_results = ActiveSupport::JSON.decode(@response.body)['results'] + assert_not_empty index_results + assert_not_nil assigns(:resource_quotas) + assert_equal @quota.id, index_results[0]['id'] + end + + test 'should show quota' do + get :show, params: { id: @quota.id }, session: set_session_user + assert_response :success + show_response = ActiveSupport::JSON.decode(@response.body) + assert_not show_response.empty? + assert_equal @quota.id, show_response['id'] + end + + test 'should not show invalid quota' do + invalid_id = ResourceQuota.all.map(&:id).sum + 1 + assert_not_nil invalid_id + get :show, params: { id: invalid_id }, session: set_session_user + assert_response :not_found + end + + test 'should create quota' do + quota_name = 'testing quota for ForemanResourceQuota::Api:V2:ResourceQuotasController.create' + quota_cpus = 128 + nof_quota_before = ResourceQuota.all.size + + put :create, params: { name: quota_name, cpu_cores: quota_cpus }, session: set_session_user + assert_response :success + created_quota = ResourceQuota.find_by(name: quota_name) + assert_not_nil created_quota + assert_quota_equal [quota_name, nil, quota_cpus, nil, nil], created_quota + assert_equal nof_quota_before + 1, ResourceQuota.all.size + end + + test 'should create quota with all attributes' do + quota_name = 'testing quota with attributes for ForemanResourceQuota::Api:V2:ResourceQuotasController.create' + quota_desc = 'testing non-empty quota description' + quota_cpus = 128 + quota_memory = 512 + quota_disk = 1024 + nof_quota_before = ResourceQuota.all.size + + put :create, params: { name: quota_name, + description: quota_desc, + cpu_cores: quota_cpus, + memory_mb: quota_memory, + disk_gb: quota_disk }, session: set_session_user + assert_response :success + created_quota = ResourceQuota.find_by(name: quota_name) + assert_not_nil created_quota + assert_quota_equal [quota_name, quota_desc, quota_cpus, quota_memory, quota_disk], created_quota + assert_equal nof_quota_before + 1, ResourceQuota.all.size + end + + test 'should not create quota without name' do + quota_cpus = 128 + nof_quota_before = ResourceQuota.all.size + + put :create, params: { cpu_cores: quota_cpus }, session: set_session_user + assert_response :unprocessable_entity + assert_equal nof_quota_before, ResourceQuota.all.size + end + + test 'should update quota' do + @quota.cpu_cores = 128 + as_admin { @quota.save! } + new_cores = 512 + + put :update, params: { id: @quota.id, resource_quota: { cpu_cores: new_cores } }, session: set_session_user + assert_response :success + updated_quota = ResourceQuota.find_by(id: @quota.id) + assert_not_nil updated_quota + assert_quota_equal [@quota.name, + @quota.description, + new_cores, + @quota.memory_mb, + @quota.disk_gb], updated_quota + end + + test 'should not update quota' do + second_quota = as_admin { FactoryBot.create :resource_quota } + as_admin { @quota.save! } + + put :update, params: { id: @quota.id, resource_quota: { name: second_quota.name } }, session: set_session_user + assert_response :unprocessable_entity + assert_not_equal @quota.name, second_quota.name + end + + test 'should destroy quota' do + nof_quota_before = ResourceQuota.all.size + put :destroy, params: { id: @quota.id }, session: set_session_user + assert_response :success + assert_equal nof_quota_before - 1, ResourceQuota.all.size + end + + test 'should not destroy any quota' do + nof_quota_before = ResourceQuota.all.size + invalid_id = ResourceQuota.all.map(&:id).sum + 1 + + assert_not_nil invalid_id + put :destroy, params: { id: invalid_id }, session: set_session_user + assert_response :not_found + assert_equal nof_quota_before, ResourceQuota.all.size + end + end + end + end +end diff --git a/test/controllers/resource_quotas_controller_test.rb b/test/controllers/resource_quotas_controller_test.rb new file mode 100644 index 0000000..d3a91da --- /dev/null +++ b/test/controllers/resource_quotas_controller_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'test_plugin_helper' + +module ForemanResourceQuota + class ResourceQuotasControllerTest < ActionController::TestCase + tests ForemanResourceQuota::ResourceQuotasController + + setup do + @quota = FactoryBot.create :resource_quota + User.current = User.find_by login: 'admin' + as_admin { @quota.save! } + end + + test 'should get index' do + get :index, session: set_session_user + assert_response :success + assert_select 'title', 'Resource quotas' + end + + test 'should destroy quota' do + put :destroy, params: { id: @quota.id }, session: set_session_user + assert_response :found + end + + test 'should not find quota to destroy' do + invalid_id = ResourceQuota.all.map(&:id).sum + 1 + assert_not_nil invalid_id + put :destroy, params: { id: invalid_id }, session: set_session_user + assert_response :not_found + end + end +end diff --git a/test/factories/foreman_resource_quota_factories.rb b/test/factories/foreman_resource_quota_factories.rb new file mode 100644 index 0000000..2f45852 --- /dev/null +++ b/test/factories/foreman_resource_quota_factories.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :resource_quota, class: 'ForemanResourceQuota::ResourceQuota' do + sequence(:name) { |n| "test resource quota#{n}" } + sequence(:description) { |n| "resource quota description#{n}" } + end + # TODO: Evaluate adding fixtures for resource origins +end diff --git a/test/helpers/hosts_helper_test.rb b/test/helpers/hosts_helper_test.rb new file mode 100644 index 0000000..40af056 --- /dev/null +++ b/test/helpers/hosts_helper_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'test_plugin_helper' + +module ForemanResourceQuota + class HostsHelperTest < ActionView::TestCase + include ::FormHelper + include ForemanResourceQuota::HostsHelper + + test 'host edit page form' do + @host = FactoryBot.create :host + quotas = [] + quotas << (FactoryBot.create :resource_quota) + quotas << (FactoryBot.create :resource_quota) + quotas << (FactoryBot.create :resource_quota) + as_admin { quotas.each(&:save!) } + + form = '' + as_admin do + form = form_for(@host) do |f| + resource_quota_select(f, ResourceQuota.all) + end + end + quotas.each do |quota| + assert form[quota.name] + end + end + end +end diff --git a/test/helpers/resource_quota_helper_test.rb b/test/helpers/resource_quota_helper_test.rb new file mode 100644 index 0000000..98a20c7 --- /dev/null +++ b/test/helpers/resource_quota_helper_test.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'test_plugin_helper' + +module ForemanResourceQuota + class ResourceQuotaHelperTest < ActiveSupport::TestCase + include ForemanResourceQuota::ResourceQuotaHelper + + test 'resource quota natural name by type' do + assert_equal 'CPU cores', natural_resource_name_by_type(:cpu_cores) + assert_equal 'Memory (MB)', natural_resource_name_by_type(:memory_mb) + assert_equal 'Disk space (GB)', natural_resource_name_by_type(:disk_gb) + end + + test 'builds missing resource per host list' do + hosts = [] + hosts << (FactoryBot.create :host) + hosts << (FactoryBot.create :host) + quota_utilization = %i[cpu_cores memory_mb disk_gb] + missing_host_res = build_missing_resources_per_host_list(hosts, quota_utilization) + assert_equal quota_utilization, missing_host_res[hosts[0].id] + assert_equal quota_utilization, missing_host_res[hosts[1].id] + end + end +end diff --git a/test/models/resource_quota_test.rb b/test/models/resource_quota_test.rb new file mode 100644 index 0000000..c584e57 --- /dev/null +++ b/test/models/resource_quota_test.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'test_plugin_helper' + +module ForemanResourceQuota + class ResourceQuotaTest < ActiveSupport::TestCase + def setup + @quota = FactoryBot.create :resource_quota + @user = FactoryBot.create :user + @usergroup = FactoryBot.create :usergroup + @host = FactoryBot.create :host + end + + test 'hosts relation' do + @quota.hosts << @host + as_admin { @quota.save! } + assert_equal @host.id, @quota.hosts[0].id + assert_equal @quota.id, @host.resource_quota.id + end + test 'users relation' do + @quota.users << @user + as_admin { @quota.save! } + assert_equal @user.id, @quota.users[0].id + assert_equal @quota.id, @user.resource_quotas[0].id + end + test 'usergroups relation' do + @quota.usergroups << @usergroup + as_admin { @quota.save! } + assert_equal @usergroup.id, @quota.usergroups[0].id + assert_equal @quota.id, @usergroup.resource_quotas[0].id + end + + test 'number of hosts' do + second_host = FactoryBot.create :host + third_host = FactoryBot.create :host + @quota.hosts << [@host, second_host, third_host] + assert_equal 3, @quota.number_of_hosts + end + test 'number of users' do + second_user = FactoryBot.create :user + third_user = FactoryBot.create :user + @quota.users << [@user, second_user, third_user] + assert_equal 3, @quota.number_of_users + end + test 'number of usergroups' do + second_usergroup = FactoryBot.create :usergroup + third_usergroup = FactoryBot.create :usergroup + @quota.usergroups << [@usergroup, second_usergroup, third_usergroup] + assert_equal 3, @quota.number_of_usergroups + end + + test 'determine utilization' do + exp_utilization = { cpu_cores: 1, memory_mb: 1, disk_gb: 2 } + exp_missing_hosts = [] + @quota.hosts << @host + as_admin { @quota.save! } + + @quota.stub(:call_utilization_helper, [exp_utilization, exp_missing_hosts]) do + @quota.determine_utilization + end + assert_equal exp_utilization.transform_keys(&:to_s), @quota.utilization + assert_equal exp_missing_hosts, @quota.missing_hosts + end + end +end diff --git a/test/services/resource_origin_test.rb b/test/services/resource_origin_test.rb new file mode 100644 index 0000000..0fe486d --- /dev/null +++ b/test/services/resource_origin_test.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +# Placeholder +# TODO: Add tests here diff --git a/test/services/resource_origins/compute_resource_origin_test.rb b/test/services/resource_origins/compute_resource_origin_test.rb new file mode 100644 index 0000000..0fe486d --- /dev/null +++ b/test/services/resource_origins/compute_resource_origin_test.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +# Placeholder +# TODO: Add tests here diff --git a/test/services/resource_origins/facts_origin_test.rb b/test/services/resource_origins/facts_origin_test.rb new file mode 100644 index 0000000..0fe486d --- /dev/null +++ b/test/services/resource_origins/facts_origin_test.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +# Placeholder +# TODO: Add tests here diff --git a/test/services/resource_origins/vm_attributes_origin_test.rb b/test/services/resource_origins/vm_attributes_origin_test.rb new file mode 100644 index 0000000..0fe486d --- /dev/null +++ b/test/services/resource_origins/vm_attributes_origin_test.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +# Placeholder +# TODO: Add tests here diff --git a/test/test_plugin_helper.rb b/test/test_plugin_helper.rb index 648a4cc..00d9da7 100644 --- a/test/test_plugin_helper.rb +++ b/test/test_plugin_helper.rb @@ -1,6 +1,31 @@ +# frozen_string_literal: true + # This calls the main test_helper in Foreman-core require 'test_helper' # Add plugin to FactoryBot's paths FactoryBot.definition_file_paths << File.join(File.dirname(__FILE__), 'factories') FactoryBot.reload + +module ActionController + class TestCase + def set_session_user(user = :admin, org = :empty_organization) + user = users(user) unless user.is_a?(User) + org = taxonomies(org) unless org.is_a?(Organization) + { user: user.id, expires_at: 5.minutes.from_now, organization_id: org.id } + end + + # Custom assertion method for checking if a given quota is equal to the expected values. + # The order of the expected values matters: [name, description, cpu_cores, memory_mb, disk_gb] + def assert_quota_equal(expexted_list, quota) + attributes = %i[name description cpu_cores memory_mb disk_gb] + attributes.each_with_index do |attr, index| + if expexted_list[index].nil? + assert_nil quota.public_send(attr) + else + assert_equal expexted_list[index], quota.public_send(attr), "#{attr} mismatch" + end + end + end + end +end diff --git a/test/unit/resource_quota_test.rb b/test/unit/resource_quota_test.rb new file mode 100644 index 0000000..60dcedb --- /dev/null +++ b/test/unit/resource_quota_test.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'test_plugin_helper' + +module ForemanResourceQuota + class ResourceQuotaTest < ActiveSupport::TestCase + # TODO: Add tests here + end +end diff --git a/webpack/api_helper.js b/webpack/api_helper.js new file mode 100644 index 0000000..083c25f --- /dev/null +++ b/webpack/api_helper.js @@ -0,0 +1,73 @@ +import { get, put, post } from 'foremanReact/redux/API'; + +/* API constants */ +const RESOURCE_QUOTA_KEY = 'RESOURCE_QUOTAS'; + +export const resourceQuotaKey = quotaId => { + if (quotaId) return `${RESOURCE_QUOTA_KEY}_${quotaId}`; + return `${RESOURCE_QUOTA_KEY}`; +}; + +/* perform an API call to list all existing Resource Quotas */ +const apiListResourceQuotas = (stateCallback, componentCallback) => + get({ + key: resourceQuotaKey(), + url: `/foreman_resource_quota/api/v2/resource_quotas`, + handleSuccess: response => stateCallback(true, response, componentCallback), + handleError: response => stateCallback(false, response, componentCallback), + }); + +/* perform an API call to get basic information on a Resource Quota */ +const apiGetResourceQuota = (quotaId, stateCallback, componentCallback) => + get({ + key: resourceQuotaKey(), + url: `/foreman_resource_quota/api/v2/resource_quotas/${quotaId}`, + handleSuccess: response => stateCallback(true, response, componentCallback), + handleError: response => stateCallback(false, response, componentCallback), + }); + +/* perform an API call to determine information on a Resource Quota's utilization */ +const apiGetResourceQuotaUtilization = ( + quotaId, + stateCallback, + componentCallback +) => + get({ + key: resourceQuotaKey(), + url: `/foreman_resource_quota/api/v2/resource_quotas/${quotaId}/utilization`, + handleSuccess: response => stateCallback(true, response, componentCallback), + handleError: response => stateCallback(false, response, componentCallback), + }); + +/* perform an API call to create a new Resource Quota */ +const apiCreateResourceQuota = (payload, stateCallback, componentCallback) => + post({ + key: resourceQuotaKey(), + url: `/foreman_resource_quota/api/v2/resource_quotas`, + params: { resource_quota: payload }, + handleSuccess: response => stateCallback(true, response, componentCallback), + handleError: response => stateCallback(false, response, componentCallback), + }); + +/* perform an API call to update Resource Quota data */ +const apiUpdateResourceQuota = ( + quotaId, + payload, + stateCallback, + componentCallback +) => + put({ + key: resourceQuotaKey(quotaId), + url: `/foreman_resource_quota/api/v2/resource_quotas/${quotaId}`, + params: { resource_quota: payload }, + handleSuccess: response => stateCallback(true, response, componentCallback), + handleError: response => stateCallback(false, response, componentCallback), + }); + +export { + apiListResourceQuotas, + apiGetResourceQuota, + apiGetResourceQuotaUtilization, + apiCreateResourceQuota, + apiUpdateResourceQuota, +}; diff --git a/webpack/components/CreateResourceQuotaModal.js b/webpack/components/CreateResourceQuotaModal.js new file mode 100644 index 0000000..30a07b4 --- /dev/null +++ b/webpack/components/CreateResourceQuotaModal.js @@ -0,0 +1,46 @@ +import React, { useState } from 'react'; +import { Button, Modal, ModalVariant } from '@patternfly/react-core'; + +import { translate as __ } from 'foremanReact/common/I18n'; + +import ResourceQuotaForm from './ResourceQuotaForm'; +import { MODAL_ID_CREATE_RESOURCE_QUOTA } from './ResourceQuotaForm/ResourceQuotaFormConstants'; + +const CreateResourceQuotaModal = () => { + const [isOpen, setIsOpen] = useState(false); + + const onSubmitSuccessCallback = success => { + if (success) { + setIsOpen(false); + window.location.reload(); + } + }; + + return ( +
+ {' '} + { + setIsOpen(false); + }} + appendTo={document.body} + > + + +
+ ); +}; + +export default CreateResourceQuotaModal; diff --git a/webpack/components/ResourceQuotaEmptyState/index.js b/webpack/components/ResourceQuotaEmptyState/index.js new file mode 100644 index 0000000..878d6b5 --- /dev/null +++ b/webpack/components/ResourceQuotaEmptyState/index.js @@ -0,0 +1,58 @@ +import React, { useState } from 'react'; +import { Button, Modal, ModalVariant } from '@patternfly/react-core'; + +import { translate as __ } from 'foremanReact/common/I18n'; +import EmptyStatePattern from 'foremanReact/components/common/EmptyState/EmptyStatePattern'; + +import ResourceQuotaForm from '../ResourceQuotaForm'; +import { MODAL_ID_CREATE_RESOURCE_QUOTA } from '../ResourceQuotaForm/ResourceQuotaFormConstants'; + +const ResourceQuotaEmptyState = () => { + const [isOpen, setIsOpen] = useState(false); + + const onSubmitSuccessCallback = success => { + if (success) { + setIsOpen(false); + window.location.reload(); + } + }; + + const ActionButton = ( + + ); + return ( +
+ + { + setIsOpen(false); + }} + appendTo={document.body} + > + + +
+ ); +}; + +export default ResourceQuotaEmptyState; diff --git a/webpack/components/ResourceQuotaForm/ResourceQuotaForm.scss b/webpack/components/ResourceQuotaForm/ResourceQuotaForm.scss new file mode 100644 index 0000000..8b81e5e --- /dev/null +++ b/webpack/components/ResourceQuotaForm/ResourceQuotaForm.scss @@ -0,0 +1 @@ +@import '~@theforeman/vendor/scss/variables'; diff --git a/webpack/components/ResourceQuotaForm/ResourceQuotaFormConstants.js b/webpack/components/ResourceQuotaForm/ResourceQuotaFormConstants.js new file mode 100644 index 0000000..d5d4465 --- /dev/null +++ b/webpack/components/ResourceQuotaForm/ResourceQuotaFormConstants.js @@ -0,0 +1,71 @@ +/* Resource identifier */ +export const RESOURCE_IDENTIFIER_ID = 'id'; +export const RESOURCE_IDENTIFIER_NAME = 'name'; +export const RESOURCE_IDENTIFIER_DESCRIPTION = 'description'; +export const RESOURCE_IDENTIFIER_CPU = 'cpu_cores'; +export const RESOURCE_IDENTIFIER_MEMORY = 'memory_mb'; +export const RESOURCE_IDENTIFIER_DISK = 'disk_gb'; +export const RESOURCE_IDENTIFIER_STATUS_NUM_HOSTS = 'number_of_hosts'; +export const RESOURCE_IDENTIFIER_STATUS_NUM_USERS = 'number_of_users'; +export const RESOURCE_IDENTIFIER_STATUS_NUM_USERGROUPS = 'number_of_usergroups'; +export const RESOURCE_IDENTIFIER_STATUS_UTILIZATION = 'utilization'; +export const RESOURCE_IDENTIFIER_STATUS_MISSING_HOSTS = 'missing_hosts'; + +/* Resource names */ +export const RESOURCE_NAME_CPU = 'CPU cores'; +export const RESOURCE_NAME_MEMORY = 'Memory'; +export const RESOURCE_NAME_DISK = 'Disk space'; + +/* Resource units (order the units with increasing factor!) */ +export const RESOURCE_UNIT_CPU = [{ symbol: 'cores', factor: 1 }]; +export const RESOURCE_UNIT_MEMORY = [ + { symbol: 'MB', factor: 1 }, + { symbol: 'GB', factor: 1000 }, + { symbol: 'TB', factor: 1000000 }, +]; +export const RESOURCE_UNIT_DISK = [ + { symbol: 'GB', factor: 1 }, + { symbol: 'TB', factor: 1000 }, + { symbol: 'PB', factor: 1000000 }, +]; + +/* Resource value bounds */ +export const RESOURCE_VALUE_MIN_CPU = 0; +export const RESOURCE_VALUE_MAX_CPU = 1999999999; +export const RESOURCE_VALUE_MIN_MEMORY = 0; +export const RESOURCE_VALUE_MAX_MEMORY = 1999999999; +export const RESOURCE_VALUE_MIN_DISK = 0; +export const RESOURCE_VALUE_MAX_DISK = 1999999999; + +/* Map attributes to given resource identifier (name, unit, minValue, maxValue) */ +export const resourceAttributesByIdentifier = identifier => { + switch (identifier) { + case RESOURCE_IDENTIFIER_CPU: + return { + name: RESOURCE_NAME_CPU, + unit: RESOURCE_UNIT_CPU, + minValue: RESOURCE_VALUE_MIN_CPU, + maxValue: RESOURCE_VALUE_MAX_CPU, + }; + case RESOURCE_IDENTIFIER_MEMORY: + return { + name: RESOURCE_NAME_MEMORY, + unit: RESOURCE_UNIT_MEMORY, + minValue: RESOURCE_VALUE_MIN_MEMORY, + maxValue: RESOURCE_VALUE_MAX_MEMORY, + }; + case RESOURCE_IDENTIFIER_DISK: + return { + name: RESOURCE_NAME_DISK, + unit: RESOURCE_UNIT_DISK, + minValue: RESOURCE_VALUE_MIN_DISK, + maxValue: RESOURCE_VALUE_MAX_DISK, + }; + default: + return null; + } +}; + +/* HTML constants */ +export const MODAL_ID_CREATE_RESOURCE_QUOTA = `foreman-resource-quota-create-modal`; +export const MODAL_ID_UPDATE_RESOURCE_QUOTA = `foreman-resource-quota-create-modal`; diff --git a/webpack/components/ResourceQuotaForm/components/Properties/Properties.scss b/webpack/components/ResourceQuotaForm/components/Properties/Properties.scss new file mode 100644 index 0000000..297fd8e --- /dev/null +++ b/webpack/components/ResourceQuotaForm/components/Properties/Properties.scss @@ -0,0 +1,9 @@ +@import '~@theforeman/vendor/scss/variables'; + +.pf-c-card__body { + padding-top: 1rem; +} + +.pf-c-content dl { + margin-bottom: 0px; +} diff --git a/webpack/components/ResourceQuotaForm/components/Properties/StaticDetail.js b/webpack/components/ResourceQuotaForm/components/Properties/StaticDetail.js new file mode 100644 index 0000000..fd19dc5 --- /dev/null +++ b/webpack/components/ResourceQuotaForm/components/Properties/StaticDetail.js @@ -0,0 +1,72 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + TextListItem, + TextListItemVariants, + TextInput, + TextArea, +} from '@patternfly/react-core'; + +import { translate as __ } from 'foremanReact/common/I18n'; + +import '../../../../lib/EditableTextInput/editableTextInput.scss'; + +const StaticDetail = ({ + value, + label, + id, + onChange, + isTextArea, + validated, + isRequired, +}) => { + const finalLabel = isRequired ? __(`${label} *`) : __(`${label}`); + + return ( + + + {finalLabel} + + + {isTextArea ? ( +