# 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' Cosmos.require_file 'json' Cosmos.require_file 'redis' Cosmos.require_file 'fileutils' Cosmos.require_file 'cosmos/utilities/zip' Cosmos.require_file 'cosmos/utilities/store' Cosmos.require_file 'cosmos/utilities/s3' Cosmos.require_file 'cosmos/utilities/sleeper' Cosmos.require_file 'cosmos/models/microservice_model' Cosmos.require_file 'cosmos/models/microservice_status_model' Cosmos.require_file 'tmpdir' module Cosmos class Microservice attr_accessor :microservice_status_thread attr_accessor :name attr_accessor :state attr_accessor :count attr_accessor :error attr_accessor :custom attr_accessor :scope def self.run microservice = self.new(ENV['COSMOS_MICROSERVICE_NAME']) begin MicroserviceStatusModel.set(microservice.as_json, scope: microservice.scope) microservice.state = 'RUNNING' microservice.run microservice.state = 'FINISHED' rescue Exception => err if err.class == SystemExit or err.class == Interrupt microservice.state = 'KILLED' else microservice.error = err microservice.state = 'DIED_ERROR' Logger.fatal("Microservice #{ENV['COSMOS_MICROSERVICE_NAME']} dying from exception\n#{err.formatted}") end ensure MicroserviceStatusModel.set(microservice.as_json, scope: microservice.scope) end end def as_json { 'name' => @name, 'state' => @state, 'count' => @count, 'error' => @error.as_json, 'custom' => @custom.as_json, 'plugin' => @plugin, } end def initialize(name, is_plugin: false) raise "Microservice must be named" unless name @name = name split_name = name.split("__") raise "Name #{name} doesn't match convention of SCOPE__TYPE__NAME" if split_name.length != 3 @scope = split_name[0] $cosmos_scope = @scope Logger.scope = @scope @cancel_thread = false @metric = Metric.new(microservice: @name, scope: @scope) Logger.microservice_name = @name Logger.tag = @name + "__cosmos.log" # Create temp folder for this microservice @temp_dir = Dir.mktmpdir # Get microservice configuration from Redis @config = MicroserviceModel.get(name: @name, scope: @scope) if @config @topics = @config['topics'] @plugin = @config['plugin'] else @config = {} @plugin = nil end Logger.info("Microservice initialized with config:\n#{@config}") @topics ||= [] # Get configuration for any targets from Minio/S3 @target_names = @config["target_names"] @target_names ||= [] System.setup_targets(@target_names, @temp_dir, scope: @scope) unless is_plugin # Use at_exit to shutdown cleanly no matter how we die at_exit do shutdown() end @count = 0 @error = nil @custom = nil @state = 'INITIALIZED' metric_name = "metric_output_duration_seconds" if is_plugin @work_dir = @config["work_dir"] cmd_array = @config["cmd"] # Get Microservice files from S3 temp_dir = Dir.mktmpdir rubys3_client = Aws::S3::Client.new bucket = "config" # Ensure config bucket exists begin rubys3_client.head_bucket(bucket: bucket) rescue Aws::S3::Errors::NotFound rubys3_client.create_bucket(bucket: bucket) end prefix = "#{@scope}/microservices/#{@name}/" file_count = 0 rubys3_client.list_objects(bucket: bucket, prefix: prefix).contents.each do |object| response_target = File.join(temp_dir, object.key.split(prefix)[-1]) FileUtils.mkdir_p(File.dirname(response_target)) rubys3_client.get_object(bucket: bucket, key: object.key, response_target: response_target) file_count += 1 end # Adjust @work_dir to microservice files downloaded if files and a relative path if file_count > 0 and @work_dir[0] != '/' @work_dir = File.join(temp_dir, @work_dir) end # Check Syntax on any ruby files ruby_filename = nil cmd_array.each do |part| if /\.rb$/.match?(part) ruby_filename = part break end end if ruby_filename Cosmos.set_working_dir(@work_dir) do if File.exist?(ruby_filename) # Run ruby syntax so we can log those syntax_check, _ = Open3.capture2e("ruby -c #{ruby_filename}") if /Syntax OK/.match?(syntax_check) Logger.info("Ruby microservice #{@name} file #{ruby_filename} passed syntax check\n", scope: @scope) else Logger.error("Ruby microservice #{@name} file #{ruby_filename} failed syntax check\n#{syntax_check}", scope: @scope) end else Logger.error("Ruby microservice #{@name} file #{ruby_filename} does not exist", scope: @scope) end end end else @microservice_sleeper = Sleeper.new @microservice_status_period_seconds = 5 @microservice_status_thread = Thread.new do until @cancel_thread start = Process.clock_gettime(Process::CLOCK_MONOTONIC) @metric.output diff = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start # seconds as a float @metric.add_sample(name: metric_name, value: diff, labels: {}) MicroserviceStatusModel.set(as_json(), scope: @scope) unless @cancel_thread break if @microservice_sleeper.sleep(@microservice_status_period_seconds) end rescue Exception => err Logger.error "#{@name} status thread died: #{err.formatted}" raise err end end end # Must be implemented by a subclass def run shutdown() end def shutdown @cancel_thread = true @microservice_sleeper.cancel if @microservice_sleeper MicroserviceStatusModel.set(as_json(), scope: @scope) FileUtils.remove_entry(@temp_dir) if File.exist?(@temp_dir) @metric.destroy end end end