require "bundler/setup" require "sinatra" require "sinatra/cookies" require "sinatra/multi_route" 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" CHUNK_SIZE = 1024**2 SESSION_SECRET_HEX_LENGTH = 64 JWT_SECRET_HEX_LENGTH = 64 ENV_PREFIX = "KUBERNAUT" CLK_TCK = 100 PID_FILE_PATH = "/run/app/pid".freeze 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 :session_secret, config.session_secret.unwrap set :public_folder, __dir__ + "/static" 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 = master_pid @procfs_f = format "/proc/%s/stat", @pid puts @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 = "./healthy" end def healthy? enabled? end end class Ready include Singleton include State include UpDown def initialize @file = "./ready" end def ready? enabled? end end class Sleep include Singleton include State def initialize @file = "./sleep" end def asleep? enabled? end def wake disable end def sleep enable end end def master_pid pid_s = File.read PID_FILE_PATH Integer pid_s.strip 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 enable :sessions 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") 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 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 "/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 "/livez/uptime" 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 "/livez/toggle" do Health.instance.toggle "ok\n" end post "/livez/sleep" do Sleep.instance.toggle "ok\n" 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", master_pid) nil end post "/halt" do Process.kill("QUIT", master_pid) nil end get "/pid" do pretty = params.key? :pretty jsonify({puma: master_pid, 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/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 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