BallAerospace/COSMOS

View on GitHub
cosmos/lib/cosmos/models/trigger_model.rb

Summary

Maintainability
B
5 hrs
Test Coverage
# 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<Hash>] All the Key, Values stored under the name key
    def self.all(group:, scope:)
      super("#{scope}#{PRIMARY_KEY}#{group}")
    end

    # @return [Array<String>] 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