Files
ubicloud/ubid.rb
2025-07-08 00:10:06 +09:00

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_BALANCERS_VMS = "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
LoadBalancersVms: 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