# encoding: ascii-8bit # Copyright 2022 Ball Aerospace & Technologies Corp. # All Rights Reserved. # # This program is free software; you can modify and/or redistribute it # under the terms of the GNU Affero General Public License # as published by the Free Software Foundation; version 3 with # attribution addendums as found in the LICENSE.txt # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # This program may also be used under the terms of a commercial or # enterprise edition license of COSMOS if purchased from the # copyright holder require 'cosmos/models/model' require 'cosmos/models/microservice_model' require 'cosmos/models/target_model' require 'cosmos/topics/autonomic_topic' module Cosmos class TriggerError < StandardError; end class TriggerInputError < TriggerError; end # INPUT: # { # "group": "someGroup", # "left": { # "type": "item", # "target": "INST", # "packet": "ADCS", # "item": "POSX", # }, # "operator": ">", # "right": { # "type": "value", # "value": 690000, # } # } class TriggerModel < Model PRIMARY_KEY = '__TRIGGERS__'.freeze ITEM_TYPE = 'item'.freeze LIMIT_TYPE = 'limit'.freeze FLOAT_TYPE = 'float'.freeze STRING_TYPE = 'string'.freeze TRIGGER_TYPE = 'trigger'.freeze def self.create_mini_id time = (Time.now.to_f * 10_000_000).to_i jitter = rand(10_000_000) key = "#{jitter}#{time}".to_i.to_s(36) return "TV0-#{key}" end # @return [TriggerModel] Return the object with the name at def self.get(name:, group:, scope:) json = super("#{scope}#{PRIMARY_KEY}#{group}", name: name) unless json.nil? self.from_json(json, name: name, scope: scope) end end # @return [Array] All the Key, Values stored under the name key def self.all(group:, scope:) super("#{scope}#{PRIMARY_KEY}#{group}") end # @return [Array] All the uuids stored under the name key def self.names(group:, scope:) super("#{scope}#{PRIMARY_KEY}#{group}") end # Check dependents before delete. def self.delete(name:, group:, scope:) model = self.get(name: name, group: group, scope: scope) if model.nil? raise TriggerInputError.new "invalid operation group: #{group} trigger: #{name} not found" end unless model.dependents.empty? raise TriggerError.new "failed to delete #{name} dependents: #{model.dependents}" end model.roots.each do | trigger | trigger_model = self.get(name: trigger, group: group, scope: scope) trigger_model.update_dependents(dependent: name, remove: true) trigger_model.update() end Store.hdel("#{scope}#{PRIMARY_KEY}#{group}", name) model.notify(kind: 'deleted') end def validate_operand(operand:) unless operand.is_a?(Hash) raise TriggerInputError.new "invalid operand: #{operand}" end operand_types = [ITEM_TYPE, LIMIT_TYPE, FLOAT_TYPE, STRING_TYPE, TRIGGER_TYPE] unless operand_types.include?(operand['type']) raise TriggerInputError.new "invalid operand type: #{operand['type']} must be of type: #{operand_types}" end if operand[operand['type']].nil? raise TriggerInputError.new "invalid operand must contain type: #{operand}" end case operand['type'] when ITEM_TYPE if operand['target'].nil? || operand['packet'].nil? || operand['raw'].nil? raise TriggerInputError.new "invalid operand must contain target, packet, item, and raw: #{operand}" end when TRIGGER_TYPE @roots << operand[operand['type']] end return operand end def validate_operator(operator:) unless operator.is_a?(String) raise TriggerInputError.new "invalid operator: #{operator}" end operators = ['>', '<', '>=', '<='] match_operators = ['==', '!='] trigger_operators = ['AND', 'OR'] if @roots.empty? && operators.include?(operator) return operator elsif @roots.empty? && match_operators.include?(operator) return operator elsif @roots.size() == 2 && trigger_operators.include?(operator) return operator elsif operators.include?(operator) raise TriggerInputError.new "invalid operator pair: '#{operator}' must be of type: #{trigger_operators}" else raise TriggerInputError.new "invalid operator: '#{operator}' must be of type: #{operators}" end end def validate_description(description:) if description.nil? left_type = @left['type'] right_type = @right['type'] return "#{@left[left_type]} #{@operator} #{@right[right_type]}" end unless description.is_a?(String) raise TriggerInputError.new "invalid description: #{description}" end return description end attr_reader :name, :scope, :state, :group, :active, :left, :operator, :right, :dependents, :roots # def initialize( name:, scope:, group:, left:, operator:, right:, state: false, active: true, description: nil, dependents: nil, updated_at: nil ) if name.nil? || scope.nil? || group.nil? || left.nil? || operator.nil? || right.nil? raise TriggerInputError.new "#{name}, #{scope}, #{group}, #{left}, #{operator}, or #{right} must not be nil" end super("#{scope}#{PRIMARY_KEY}#{group}", name: name, scope: scope) @roots = [] @group = group @state = state @active = active @left = validate_operand(operand: left) @right = validate_operand(operand: right) @operator = validate_operator(operator: operator) @description = validate_description(description: description) @dependents = dependents @updated_at = updated_at end def verify_triggers unless @group.is_a?(String) raise TriggerInputError.new "invalid group: #{@group}" end selected_group = Cosmos::TriggerGroupModel.get(name: @group, scope: @scope) if selected_group.nil? raise TriggerGroupInputError.new "failed to find group: #{@group}" end @dependents = [] if @dependents.nil? @roots.each do | trigger | model = TriggerModel.get(name: trigger, group: @group, scope: @scope) if model.nil? raise TriggerInputError.new "failed to find dependent trigger: #{trigger}" end if model.group != @group raise TriggerInputError.new "failed group dependent trigger: #{trigger}" end unless model.dependents.include?(@name) model.update_dependents(dependent: @name) model.update() end end end def create unless Store.hget(@primary_key, @name).nil? raise TriggerInputError.new "exsisting Trigger found: #{@name}" end verify_triggers() @updated_at = Time.now.to_nsec_from_epoch Store.hset(@primary_key, @name, JSON.generate(as_json())) notify(kind: 'created') end def update verify_triggers() @updated_at = Time.now.to_nsec_from_epoch Store.hset(@primary_key, @name, JSON.generate(as_json())) notify(kind: 'updated') end def enable @state = true @updated_at = Time.now.to_nsec_from_epoch Store.hset(@primary_key, @name, JSON.generate(as_json())) notify(kind: 'enabled') end def disable @state = false @updated_at = Time.now.to_nsec_from_epoch Store.hset(@primary_key, @name, JSON.generate(as_json())) notify(kind: 'disabled') end def activate @active = true @updated_at = Time.now.to_nsec_from_epoch Store.hset(@primary_key, @name, JSON.generate(as_json())) notify(kind: 'activated') end def deactivate @active = false @state = false @updated_at = Time.now.to_nsec_from_epoch Store.hset(@primary_key, @name, JSON.generate(as_json())) notify(kind: 'deactivated') end def modify raise "TODO" end # ["#{@scope}__DECOM__{#{@target}}__#{@packet}"] def generate_topics topics = Hash.new if @left['type'] == ITEM_TYPE topics["#{@scope}__DECOM__{#{left['target']}}__#{left['packet']}"] = 1 end if @right['type'] == ITEM_TYPE topics["#{@scope}__DECOM__{#{right['target']}}__#{right['packet']}"] = 1 end return topics.keys end def update_dependents(dependent:, remove: false) if remove @dependents.delete(dependent) elsif @dependents.index(dependent).nil? @dependents << dependent end end # @return [String] generated from the TriggerModel def to_s return "(TriggerModel :: #{@name} :: #{group} :: #{@description})" end # @return [Hash] generated from the TriggerModel def as_json return { 'name' => @name, 'scope' => @scope, 'state' => @state, 'active' => @active, 'group' => @group, 'description' => @description, 'dependents' => @dependents, 'left' => @left, 'operator' => @operator, 'right' => @right, 'updated_at' => @updated_at, } end # @return [TriggerModel] Model generated from the passed JSON def self.from_json(json, name:, scope:) json = JSON.parse(json) if String === json raise "json data is nil" if json.nil? json.transform_keys!(&:to_sym) self.new(**json, name: name, scope: scope) end # @return [] update the redis stream / trigger topic that something has changed def notify(kind:) notification = { 'kind' => kind, 'type' => 'trigger', 'data' => JSON.generate(as_json()), } AutonomicTopic.write_notification(notification, scope: @scope) end # @param [String] kind - the status such as "event" or "error" # @param [String] message - an optional message to include in the event def log(kind:, message: nil) notification = { 'kind' => kind, 'type' => 'log', 'time' => Time.now.to_i, 'name' => @name, } notification['message'] = message unless message.nil? AutonomicTopic.write_notification(notification, scope: @scope) end end end