410 lines
11 KiB
Ruby
410 lines
11 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require("securerandom")
|
|
|
|
class UBIDParseError < RuntimeError
|
|
end
|
|
|
|
class UBID
|
|
# Binary format, which is UUIDv8 compatible, is:
|
|
# 0 1 2 3
|
|
# 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
|
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
# | 32_bit_uint_time_high |
|
|
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
# | 16_bit_uint_time_low | ver |r_1| type_1 | type_2 |
|
|
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
# |var| r_2 |
|
|
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
# | r_2 |
|
|
# +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
#
|
|
# String format is:
|
|
# base32(type_1) + base32(type_2) + \
|
|
# base32_n(msb[0..53] * 2 + parity(msb[0..53])) + \
|
|
# base32_n(msb[64..127] * 2 + parity(msb[64..127))
|
|
|
|
# we have 64 random bits in the format, so 2^64 - 1
|
|
MAX_ENTROPY = 18446744073709551615
|
|
|
|
# timestamp is 48 bits, so 2^48 - 1
|
|
MAX_TIMESTAMP = 281474976710655
|
|
|
|
# types
|
|
TYPE_VM = "vm"
|
|
TYPE_VM_STORAGE_VOLUME = "v1"
|
|
TYPE_VM_HOST = "vh"
|
|
TYPE_STORAGE_KEY_ENCRYPTION_KEY = "ke"
|
|
TYPE_PROJECT = "pj"
|
|
# TYPE_ACCESS_TAG = "tg"
|
|
# TYPE_ACCESS_POLICY = "pc"
|
|
TYPE_ACCOUNT = "ac"
|
|
TYPE_IPSEC_TUNNEL = "tn"
|
|
TYPE_PRIVATE_SUBNET = "ps"
|
|
TYPE_ADDRESS = "ad"
|
|
TYPE_ASSIGNED_VM_ADDRESS = "av"
|
|
TYPE_ASSIGNED_HOST_ADDRESS = "ah"
|
|
TYPE_STRAND = "st"
|
|
TYPE_SEMAPHORE = "sm"
|
|
TYPE_SSHABLE = "sh"
|
|
TYPE_PAGE = "pa"
|
|
TYPE_NIC = "nc"
|
|
TYPE_BILLING_RECORD = "br"
|
|
TYPE_INVOICE = "1v"
|
|
TYPE_BILLING_INFO = "b1"
|
|
TYPE_PAYMENT_METHOD = "pm"
|
|
TYPE_DISCOUNT_CODE = "dc"
|
|
TYPE_PROJECT_DISCOUNT_CODE = "pd"
|
|
TYPE_GITHUB_INSTALLATION = "g1"
|
|
TYPE_GITHUB_RUNNER = "gr"
|
|
TYPE_VM_POOL = "vp"
|
|
TYPE_POSTGRES_RESOURCE = "pg"
|
|
TYPE_POSTGRES_SERVER = "pv"
|
|
TYPE_POSTGRES_TIMELINE = "pt"
|
|
TYPE_MINIO_CLUSTER = "mc"
|
|
TYPE_MINIO_POOL = "mp"
|
|
TYPE_MINIO_SERVER = "ms"
|
|
TYPE_DNS_ZONE = "dz"
|
|
TYPE_DNS_RECORD = "dr"
|
|
TYPE_DNS_SERVER = "ds"
|
|
TYPE_FIREWALL_RULE = "fr"
|
|
TYPE_FIREWALL = "fw"
|
|
TYPE_POSTGRES_FIREWALL_RULE = "pf"
|
|
TYPE_GITHUB_REPOSITORY = "gp"
|
|
TYPE_LOAD_BALANCER = "1b"
|
|
TYPE_LOAD_BALANCER_PORT = "1p"
|
|
TYPE_LOAD_BALANCER_VM = "1s"
|
|
TYPE_LOAD_BALANCER_VM_PORT = "1q"
|
|
TYPE_CERT = "ce"
|
|
TYPE_INFERENCE_ENDPOINT = "1e"
|
|
TYPE_INFERENCE_ENDPOINT_REPLICA = "1r"
|
|
TYPE_INFERENCE_ROUTER = "r0"
|
|
TYPE_INFERENCE_ROUTER_REPLICA = "rr"
|
|
TYPE_INFERENCE_ROUTER_MODEL = "rm"
|
|
TYPE_INFERENCE_ROUTER_TARGET = "rt"
|
|
TYPE_ACTION_TYPE = "tt"
|
|
TYPE_ACCESS_CONTROL_ENTRY = "te"
|
|
TYPE_SUBJECT_TAG = "ts"
|
|
TYPE_ACTION_TAG = "ta"
|
|
TYPE_OIDC_PROVIDER = "0p"
|
|
TYPE_OBJECT_TAG = "t0"
|
|
TYPE_OBJECT_METATAG = "t2"
|
|
TYPE_VM_HOST_SLICE = "vs"
|
|
TYPE_KUBERNETES_CLUSTER = "kc"
|
|
TYPE_KUBERNETES_NODEPOOL = "kn"
|
|
TYPE_LOCATION = "10"
|
|
TYPE_API_KEY = "ak"
|
|
TYPE_USAGE_ALERT = "va"
|
|
TYPE_POSTGRES_METRIC_DESTINATION = "md"
|
|
TYPE_GITHUB_CACHE_ENTRY = "ge"
|
|
TYPE_LOCATION_CREDENTIAL = "1c"
|
|
TYPE_PRIVATE_SUBNET_AWS_RESOURCE = "aw"
|
|
TYPE_NIC_AWS_RESOURCE = "na"
|
|
TYPE_VICTORIA_METRICS_RESOURCE = "vr"
|
|
TYPE_VICTORIA_METRICS_SERVER = "vn"
|
|
# TYPE_AUDIT_LOG = "a1"
|
|
|
|
# Common entropy-based type for everything else
|
|
TYPE_ETC = "et"
|
|
|
|
CURRENT_TIMESTAMP_TYPES = [TYPE_STRAND, TYPE_SEMAPHORE]
|
|
|
|
def self.generate(type)
|
|
case type
|
|
when *CURRENT_TIMESTAMP_TYPES
|
|
generate_from_current_ts(type)
|
|
else
|
|
generate_random(type)
|
|
end
|
|
end
|
|
|
|
def self.generate_random(type)
|
|
timestamp = SecureRandom.random_number(MAX_TIMESTAMP)
|
|
random_value = SecureRandom.random_number(MAX_ENTROPY)
|
|
from_parts(timestamp, type, random_value & 0b11, random_value >> 2)
|
|
end
|
|
|
|
def self.generate_from_current_ts(type)
|
|
random_value = SecureRandom.random_number(MAX_ENTROPY)
|
|
from_parts(current_milliseconds, type, random_value & 0b11, random_value >> 2)
|
|
end
|
|
|
|
# InferenceApiKey does not have a type, and using et (TYPE_ETC) seems like a bad idea
|
|
ACTION_TYPE_PREFIX_MAP = <<~TYPES.split("\n").map! { it.split(": ") }.to_h.freeze
|
|
Project: pj
|
|
Vm: vm
|
|
PrivateSubnet: ps
|
|
Firewall: fw
|
|
LoadBalancer: 1b
|
|
LoadBalancerVm: 1s
|
|
InferenceEndpoint: 1e
|
|
InferenceApiKey: 1t
|
|
Postgres: pg
|
|
SubjectTag: ts
|
|
ActionTag: ta
|
|
ObjectTag: t0
|
|
KubernetesCluster: kc
|
|
Location: 10
|
|
TYPES
|
|
def self.generate_vanity_action_type(action)
|
|
prefix, suffix = action.split(":")
|
|
prefix = ACTION_TYPE_PREFIX_MAP.fetch(prefix)
|
|
generate_vanity("tt", prefix, suffix[0...7].tr("u", "v"))
|
|
end
|
|
|
|
def self.generate_vanity_action_tag(name)
|
|
prefix, suffix = name.split(":")
|
|
if suffix
|
|
prefix = ACTION_TYPE_PREFIX_MAP.fetch(prefix)
|
|
else
|
|
prefix = nil
|
|
suffix = name
|
|
end
|
|
generate_vanity("ta", prefix, suffix[0...7].tr("u", "v"))
|
|
end
|
|
|
|
def self.generate_vanity(type, prefix, suffix)
|
|
raise "prefix over length 2" if prefix && prefix.length != 2
|
|
raise "suffix over length 7" unless suffix.length <= 7
|
|
full = "#{"0" if prefix}#{prefix}0#{suffix}".rjust(11, "z")
|
|
from_parts(UBID.to_base32_n("zzzzzzzz") * 256, type, 0, UBID.to_base32_n(full) * 16)
|
|
end
|
|
|
|
def self.camelize(s)
|
|
s.delete_prefix("TYPE").split("_").map(&:capitalize).join
|
|
end
|
|
|
|
# Map of prefixes to class name symbols, to avoid autoloading
|
|
# classes until they are referenced by class_for_ubid
|
|
TYPE2CLASSNAME = constants.select { it.start_with?("TYPE_") }.reject { it.to_s == "TYPE_ETC" }
|
|
.map { [const_get(it), camelize(it.to_s).to_sym] }.to_h.freeze
|
|
private_constant :TYPE2CLASSNAME
|
|
|
|
def self.class_for_ubid(str)
|
|
# :nocov:
|
|
# Overridden in production and when forcing autoloads in tests
|
|
if (sym = TYPE2CLASSNAME[str[..1]])
|
|
Object.const_get(sym)
|
|
end
|
|
# :nocov:
|
|
end
|
|
|
|
def self.resolve_map(uuids)
|
|
uuids.keys.group_by do
|
|
ubid = from_uuidish(it).to_s
|
|
# Bad hack, needed because ApiKey does not use a fixed ubid type
|
|
ubid.start_with?("et") ? ApiKey : class_for_ubid(ubid)
|
|
end.each do |model, model_uuids|
|
|
next unless model
|
|
model.where(id: model_uuids).each do
|
|
uuids[it.id] = it
|
|
end
|
|
end
|
|
uuids
|
|
end
|
|
|
|
def self.decode(ubid)
|
|
ubid_str = ubid.to_s
|
|
uuid = UBID.parse(ubid_str).to_uuid
|
|
klass = class_for_ubid(ubid)
|
|
fail UBIDParseError.new("Couldn't decode ubid: #{ubid_str}") if klass.nil?
|
|
|
|
klass[uuid]
|
|
end
|
|
|
|
def self.from_uuidish(uuidish)
|
|
value = Integer(uuidish.to_s.tr("-", ""), 16)
|
|
new(value)
|
|
end
|
|
|
|
def self.to_uuid(ubid_str)
|
|
parse(ubid_str).to_uuid
|
|
rescue UBIDParseError
|
|
end
|
|
|
|
def self.uuid_class_match?(uuid, klass)
|
|
uuid_type_match?(uuid, klass.ubid_type)
|
|
end
|
|
|
|
def self.uuid_type_match?(uuid, type)
|
|
from_uuidish(uuid).type_match?(type)
|
|
end
|
|
|
|
def self.class_match?(ubid, klass)
|
|
type_match?(ubid, klass.ubid_type)
|
|
end
|
|
|
|
def self.type_match?(ubid, type)
|
|
ubid[..1] == type
|
|
end
|
|
|
|
def class_match?(klass)
|
|
type_match?(klass.ubid_type)
|
|
end
|
|
|
|
def type_match?(type)
|
|
to_s[..1] == type
|
|
end
|
|
|
|
def to_uuid
|
|
# 8-4-4-4-12 format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
|
a = UBID.extract_bits_as_hex(@value, 24, 8)
|
|
b = UBID.extract_bits_as_hex(@value, 20, 4)
|
|
c = UBID.extract_bits_as_hex(@value, 16, 4)
|
|
d = UBID.extract_bits_as_hex(@value, 12, 4)
|
|
e = UBID.extract_bits_as_hex(@value, 0, 12)
|
|
"#{a}-#{b}-#{c}-#{d}-#{e}"
|
|
end
|
|
|
|
def self.parse(s)
|
|
fail UBIDParseError.new("Invalid encoding length: #{s.length}") unless s.length == 26
|
|
|
|
type = s[0..1]
|
|
|
|
top_bits_with_parity = to_base32_n(s[2..12])
|
|
fail UBIDParseError.new("Invalid top bits parity") unless parity(top_bits_with_parity) == 0
|
|
top_bits = (top_bits_with_parity >> 1)
|
|
unix_ts_ms = get_bits(top_bits, 6, 53)
|
|
version = get_bits(top_bits, 2, 5)
|
|
rand_a = get_bits(top_bits, 0, 1)
|
|
|
|
bottom_bits_with_parity = to_base32_n(s[13..])
|
|
fail UBIDParseError.new("Invalid bottom bits parity") unless parity(bottom_bits_with_parity) == 0
|
|
bottom_bits = (bottom_bits_with_parity >> 1)
|
|
variant = get_bits(bottom_bits, 62, 63)
|
|
rand_b = get_bits(bottom_bits, 0, 61)
|
|
|
|
from_parts(unix_ts_ms, type, rand_a, rand_b, version: version, variant: variant)
|
|
end
|
|
|
|
def self.from_parts(unix_ts_ms, type, rand_a, rand_b, version: 0b1000, variant: 0b10)
|
|
value = 0
|
|
# timestamp (48 bits)
|
|
value |= (unix_ts_ms & 0xffffffffffff) << 80
|
|
# version (4 bits)
|
|
value = set_bits(value, 76, 79, version)
|
|
# rand_a (2 bits)
|
|
value = set_bits(value, 74, 75, rand_a & 0b11)
|
|
# type char 1 (5 bits)
|
|
value = set_bits(value, 69, 73, to_base32(type[0]))
|
|
# type char 2 (5 bits)
|
|
value = set_bits(value, 64, 68, to_base32(type[1]))
|
|
# variant (2 bits)
|
|
value = set_bits(value, 62, 63, variant)
|
|
# rand_b (62 bits)
|
|
value |= (rand_b & 0x3fffffffffffffff)
|
|
|
|
new(value)
|
|
end
|
|
|
|
def initialize(value)
|
|
@value = value
|
|
end
|
|
|
|
def to_s
|
|
result = ""
|
|
# type
|
|
result += UBID.from_base32(UBID.get_bits(@value, 69, 73))
|
|
result += UBID.from_base32(UBID.get_bits(@value, 64, 68))
|
|
# top-bits: 127..74 (54 bits)
|
|
top_bits = UBID.get_bits(@value, 74, 127)
|
|
result += UBID.from_base32_n((top_bits << 1) | UBID.parity(top_bits), 11)
|
|
# bottom-bits: 63..0 (64 bits)
|
|
bottom_bits = UBID.get_bits(@value, 0, 63)
|
|
result += UBID.from_base32_n((bottom_bits << 1) | UBID.parity(bottom_bits), 13)
|
|
result
|
|
end
|
|
|
|
def to_i
|
|
@value
|
|
end
|
|
|
|
def inspect
|
|
"#<UBID:#{TYPE2CLASSNAME[to_s[..1]] || "Unknown"} @ubid=#{to_s.inspect} @uuid=#{to_uuid.inspect}>"
|
|
end
|
|
|
|
#
|
|
# Utility functions
|
|
#
|
|
|
|
def self.current_milliseconds
|
|
(Time.now.to_r * 1000).to_i
|
|
end
|
|
|
|
def self.set_bits(n, from, to, bits)
|
|
(from..to).each { |i|
|
|
if (bits & (1 << (i - from))) != 0
|
|
n |= (1 << i)
|
|
end
|
|
}
|
|
n
|
|
end
|
|
|
|
def self.get_bits(n, from, to)
|
|
result = 0
|
|
(from..to).each { |i|
|
|
if (n & (1 << i)) != 0
|
|
result |= (1 << (i - from))
|
|
end
|
|
}
|
|
result
|
|
end
|
|
|
|
BASE32_DATA = [
|
|
"0O",
|
|
"1IL",
|
|
"2", "3", "4", "5", "6", "7", "8", "9",
|
|
"A", "B", "C", "D", "E", "F", "G", "H",
|
|
"J", "K", "M", "N", "P", "Q", "R", "S",
|
|
"T", "V", "W", "X", "Y", "Z"
|
|
]
|
|
|
|
def self.to_base32(c)
|
|
c = c.upcase
|
|
BASE32_DATA.each_with_index do |e, idx|
|
|
if e.include? c
|
|
return idx
|
|
end
|
|
end
|
|
|
|
fail UBIDParseError.new("Invalid base32 encoding: #{c}")
|
|
end
|
|
|
|
def self.to_base32_n(s)
|
|
result = 0
|
|
s.chars.each {
|
|
result = result * 32 + to_base32(it)
|
|
}
|
|
result
|
|
end
|
|
|
|
def self.from_base32(num)
|
|
fail UBIDParseError.new("Invalid base32 number: #{num}") if num < 0 || num >= 32
|
|
BASE32_DATA[num][0].downcase
|
|
end
|
|
|
|
def self.from_base32_n(num, cnt)
|
|
(cnt - 1).downto(0).map { |i|
|
|
from_base32(get_bits(num, 5 * i, 5 * i + 4))
|
|
}.join
|
|
end
|
|
|
|
def self.parity(num)
|
|
if num == 0
|
|
0
|
|
elsif (num & 1) == 0
|
|
parity(num >> 1)
|
|
else
|
|
1 - parity(num >> 1)
|
|
end
|
|
end
|
|
|
|
def self.extract_bits_as_hex(value, from_digit, digit_count)
|
|
bit_count = digit_count * 4
|
|
from_bit = from_digit * 4
|
|
bitmask = (1 << bit_count) - 1
|
|
bits_i = (value & (bitmask << from_bit)) >> from_bit
|
|
bits_i.to_s(16).rjust(digit_count, "0")
|
|
end
|
|
end
|