require "bundler/setup" require "sinatra" require "sinatra/cookies" require "sinatra/multi_route" require "sinatra/quiet_logger" require "time" require "fileutils" require "json" require "singleton" require "securerandom" require "random/formatter" require "ulid" require "anyflake" require "jwt" require "httparty" $LOAD_PATH.unshift File.dirname(__FILE__) + "/lib" require "config" VERSION = "0.1.3" CHUNK_SIZE = 1024**2 SESSION_SECRET_HEX_LENGTH = 64 JWT_SECRET_HEX_LENGTH = 64 DEFAULT_FLAKEY = 50 NAME = "kubernaut".freeze ENV_PREFIX = NAME.upcase CLK_TCK = 100 PROC_UPTIME_PATH = "/proc/uptime".freeze SECONDS_PER_YEAR = 31_556_952 SECONDS_PER_MINUTE = 60 SECONDS_PER_HOUR = 3_600 SECONDS_PER_DAY = 86_400 SECONDS_PER_WEEK = 604_800 SECONDS_PER_MONTH = SECONDS_PER_YEAR / 12 DURATION_PARTS = [ [SECONDS_PER_YEAR, "year", "y"], [SECONDS_PER_MONTH, "month", "m"], [SECONDS_PER_WEEK, "week", "w"], [SECONDS_PER_DAY, "day", "d"], [SECONDS_PER_HOUR, "hour", "h"], [SECONDS_PER_MINUTE, "minute", "m"], [1, "second", "s"] ].freeze config = Config.new set :quiet_logger_prefixes, %w[livez readyz] set :session_secret, config.session_secret.unwrap set :public_folder, __dir__ + "/static" register Sinatra::QuietLogger module Sinatra module RequestHeadersHelper def req_headers hash = request.env.select { |k, _| k.start_with? "HTTP_" } .collect { |k, v| [k.gsub(/^HTTP_/, "").tr("_", "-").downcase, v] } .sort h = Rack::Headers.new h.merge hash end end helpers RequestHeadersHelper end module State def enable FileUtils.touch(@file) end def disable File.delete @file if File.file? @file end def enabled? File.file? @file end def toggle return enable unless enabled? disable end end module UpDown def up enable end def down disable end def to_s enabled? ? "up" : "down" end def to_json(*_args) return unless enabled? JSON.generate({"status" => "ok"}) end end class TickTock def initialize @pid = ppid @procfs_f = format "/proc/%s/stat", @pid end def uptime x = "" File.open @procfs_f do |f| f.each_line do |l| x = l.strip.split end end system_uptime - (Integer(x[21]) / CLK_TCK) end def started_at Time.now - uptime end end class Health include Singleton include State include UpDown def initialize @file = "/dev/shm/healthy" end def healthy? enabled? end end class Ready include Singleton include State include UpDown def initialize @file = "/dev/shm/ready" end def ready? enabled? end end class Sleep include Singleton include State def initialize @file = "/dev/shm/sleepy" end def asleep? enabled? end def wake disable end def sleep enable end end def ppid pid = ENV.fetch "PUMA_PID", Process.pid begin Integer pid rescue ArgumentError -1 end end def system_uptime uptime_s = File.read PROC_UPTIME_PATH Float uptime_s.strip.split[0] end ## # Convert to human friendly time interval # # @param [Integer] time in seconds def human_time(seconds, delim = " ") s = [] DURATION_PARTS.each do |divisor, unit, abbrev| q = seconds / divisor r = seconds % divisor s.push "#{q}#{abbrev}" if q.positive? seconds = r end s.join delim end Health.instance.up Ready.instance.up Sleep.instance.wake def flaky(pct = DEFAULT_FLAKEY) r = Random.rand(0..100) unless r < (100 - pct) halt 500, "so unreliable" end end enable :sessions puts "#{NAME} #{VERSION} staring, per aspera ad astra" configure do mime_type :json, "application/json" end before do # content_type 'text/plain' sleep(1) while Sleep.instance.asleep? && request.path_info != "/livez/sleep" content_type :text if request.path_info.start_with? "/_cat" request.session_options[:skip] = !request.path_info.start_with?("/session") if params.has_key? :flaky begin pct = Integer(params[:flaky]) pct = pct.clamp(0, 100) rescue => e logger.warn "#{e.message}: falling back to default flaky percentage of #{DEFAULT_FLAKEY}" pct = DEFAULT_FLAKEY end flaky(pct) end end helpers do def jsonify(obj, opts: nil, pretty: false) buf = if pretty JSON.pretty_generate obj, opts: else JSON.generate(obj, opts:) end "#{buf}\n" end def protected! hidden = false return if authorized? if hidden halt 404, "Not Found" else headers["WWW-Authenticate"] = 'Basic realm="Restricted Area"' halt 401, "Unauthorized" end end def authorized? @auth ||= Rack::Auth::Basic::Request.new(request.env) @auth.provided? and @auth.basic? and @auth.credentials and @auth.credentials == ["qwer", "asdf"] end def hostname ENV["HOSTNAME"] end end get "/" do "hello there!\n" end get "/env", provides: "json" do pretty = params.key? :pretty jsonify ENV.sort.to_h, pretty: end get "/headers", provides: "json" do pretty = params.key? :pretty h = req_headers jsonify h, pretty: end get "/uptime", provides: "json" do tt = TickTock.new x = {started_at: tt.started_at, seconds: tt.uptime.to_i, human: human_time(tt.uptime.to_i)} jsonify x end post "/api/livez/toggle" do Health.instance.toggle "ok\n" end post "/api/livez/sleep" do Sleep.instance.toggle "ok\n" end get "/livez" do error 503 unless Health.instance.healthy? return Health.instance.to_json if request.env["HTTP_ACCEPT"] == "application/json" Health.instance.to_s end get "/readyz" do error 503 unless Ready.instance.ready? return Ready.instance.to_json if request.env["HTTP_ACCEPT"] == "application/json" Ready.instance.to_s end get "/uuid" do n = params.fetch(:n, 1).to_i stream do |out| n.times do |_| out << format("%s\n", SecureRandom.uuid) end end end get "/ulid" do n = params.fetch(:n, 1).to_i stream do |out| n.times do |_| out << format("%s\n", ULID.generate) end end end get "/snowflake" do n = params.fetch(:n, 1).to_i epoch = Time.new(2016, 7, 1, 0, 0, 0).strftime("%s%L").to_i node_id = 1 af = AnyFlake.new(epoch, node_id) stream do |out| n.times do |_| out << format("%s\n", af.next_id) end end end post "/quit" do Process.kill("TERM", ppid) nil end post "/halt" do Process.kill("QUIT", ppid) nil end get "/pid" do pretty = params.key? :pretty jsonify({ppid: ppid, pid: Process.pid}, pretty:) end get "/token" do exp = Time.now.to_i + SECONDS_PER_MINUTE * 2 payload = {name: "anonymous", exp: exp, jti: Random.uuid} expires_at = Time.at(exp).to_datetime token = JWT.encode payload, JWT_SECRET, "HS256" x = {token: token, expires_at: expires_at} jsonify x end get "/token/validate" do token = req_headers["authorization"].split[1] payload = JWT.decode token, JWT_SECRET, true, algorithm: "HS256" jsonify payload end post "/session" do session.merge! params jsonify session.to_hash end get "/session" do j = session.to_hash j[:hostname] = ENV["HOSTNAME"] jsonify j end get "/cookies" do jsonify response.headers end get "/config", provides: "json" do pretty = params.key? :pretty jsonify config.as_json, pretty: end get "/_cat" do stream do |out| out << "=^.^=\n" x = Sinatra::Application.routes.map do |method, route| route.map do |route| route.first.to_s end end x.flatten.sort.uniq.each do |route| out << "#{route}\n" if route.start_with? "/_cat" end end end get "/_cat/headers" do stream do |out| req_headers.each do |k, v| out << "#{k}: #{v.inspect}\n" end end end get "/_cat/env" do stream do |out| ENV.sort.each do |k, v| out << "#{k}=#{v}\n" end end end get "/_cat/cookies" do stream do |out| cookies.each do |k, v| out << "#{k}=#{v}\n" end end end get "/_cat/config" do stream do |out| config.instance_variables.each do |k| k = k.to_s.delete_prefix "@" begin v = config.send(k) rescue NoMethodError next end out << "#{k}(#{v.to_s.length})=#{v}\n" end end end get "/_cat/pid" do stream do |out| {ppid: ppid, pid: Process.pid}.sort.each do |k, v| out << "#{k}=#{v}\n" end end end route :delete, :get, :patch, :post, :put, "/status/:code" do # hello code = Integer(params[:code]) status code if code.between? 100, 599 end get "/chunked/:delay" do content_type "application/x-ndjson" delay = Float(params[:delay]) stream do |out| 30.times do |i| out << jsonify({id: i, message: (i % 2).zero? ? "tick" : "tock"}) sleep delay end end end route :delete, :get, :patch, :post, :put, "/auth/basic", provides: "json" do pretty = params.key? :pretty if params.key? :hidden protected! hidden: true else protected! end jsonify({authenticated: true, user: @auth.username}, pretty:) end def human_size_to_bytes(size) units = %i[b kb mb gb tb pb eb zb yb rb qb] number, unit = size.split(/(?<=\d)(?=[A-Za-z])/) raise ArgumentError, "the unit is not recognized" if unit.nil? number = Float(number) unit = unit.downcase.to_sym exponent = units.find_index(unit) number *= (1024**exponent) Integer(number.ceil) end MAX_DOWNLOAD_SIZE = "1GB" get "/bytes/:size" do size = params[:size] n = [human_size_to_bytes(size), human_size_to_bytes(MAX_DOWNLOAD_SIZE)].min headers["content-type"] = "application/octet-stream" headers["content-length"] = n headers["content-disposition"] = "attachment; filename=\"#{params[:f]}\"" if params.key? :f def generate_bytes(number, byte = "\x00", block_size = 4096) raise ArgumentError, "'byte' must be 1 byte" unless byte.b.length == 1 bytes_written = 0 block = byte * block_size Enumerator.new do |g| while bytes_written < number remaining_bytes = number - bytes_written bytes_to_write = [block_size, remaining_bytes].min g.yield block[0, bytes_to_write] bytes_written += bytes_to_write end end end generate_bytes(Integer(n)) end get "/api/caas" do send_file Dir[__dir__ + "/static/cat*.jpg"].sample end get "/meow" do caas_host = ENV.fetch "CAAS_SERVICE_HOST", nil caas_port = ENV.fetch "CAAS_SERVICE_PORT", nil url = "http://#{caas_host}:#{caas_port}/" unless caas_host && caas_port url = url("/api/caas") end tmp_file = Tempfile.open binmode: true do |f| # f.chmod 0o644 response = HTTParty.get(url, stream_body: true) do |fragment| if [301, 302].include? fragment.code print "skip writing for redirect" elsif fragment.code == 200 f.write fragment else raise StandardError, "non-success status code while streaming #{fragment.code}" end end content_type response.headers["content-type"] f end tmp_file.open do |f| stream do |out| out << f.read until f.eof? out << f.read(CHUNK_SIZE) end end ensure f.close f.unlink end end